diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 466259f6f8..a2087025b7 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -295,4 +295,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,smtp --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,http_trigger --all -- --nocapture --test-threads=10 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b468387057..c94521d332 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -454,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 diff --git a/.github/workflows/publish_extra.yml b/.github/workflows/publish_extra.yml index ca62933292..863c850266 100644 --- a/.github/workflows/publish_extra.yml +++ b/.github/workflows/publish_extra.yml @@ -35,6 +35,24 @@ jobs: with: bun-version: latest + # Matches the Node major DockerfileExtra installs. The pin is load-bearing: + # glob arguments to `node --test` need Node >= 21. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run multiplayer unit tests + working-directory: multiplayer + run: | + npm ci --omit=dev + npm test + + # Runs the real entrypoint-extra.sh with stub services, so the supervision + # behaviour is checked without building the image first. + - name: Run entrypoint supervision tests + run: bash docker/test_entrypoint_extra.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6dc5574962..6046035ab7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.817.0" + ".": "1.819.0" } diff --git a/AGENTS.md b/AGENTS.md index 77b38c3267..858e9329ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain `cargo run`; a normal build cannot start one at all. - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow +- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are + enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` + fields and a jsonb merge rather than a serde default - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates diff --git a/CHANGELOG.md b/CHANGELOG.md index 595426d619..93b0e0dd9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [1.819.0](https://github.com/windmill-labs/windmill/compare/v1.818.0...v1.819.0) (2026-09-28) + + +### Features + +* add hub sync button to the resource types tab ([#11375](https://github.com/windmill-labs/windmill/issues/11375)) ([d76a962](https://github.com/windmill-labs/windmill/commit/d76a962331784672934749e2237f83259cbbc633)) +* add provenance claims to job OIDC tokens ([#11369](https://github.com/windmill-labs/windmill/issues/11369)) ([990a726](https://github.com/windmill-labs/windmill/commit/990a72640928336a4583e55376f37aa3c9eb17ea)) + + +### Bug Fixes + +* complete a canceled flow whose worker died between two steps ([#11366](https://github.com/windmill-labs/windmill/issues/11366)) ([c2d8997](https://github.com/windmill-labs/windmill/commit/c2d899754977ab95936b1084817da98d7e5d68f4)) +* **frontend:** apply operator write locks from the session's operating workspace ([#11395](https://github.com/windmill-labs/windmill/issues/11395)) ([5e59cef](https://github.com/windmill-labs/windmill/commit/5e59cefd1fa579148a06bb687aa60d1de21826b5)) +* gate batch rerun on job read access, scope started_at to workspace ([#11387](https://github.com/windmill-labs/windmill/issues/11387)) ([14a2619](https://github.com/windmill-labs/windmill/commit/14a2619ad24d98592821b12b2e6a54cbcfbc3d09)) +* hold interpolated references and captures to the token path scopes ([#11391](https://github.com/windmill-labs/windmill/issues/11391)) ([cedd6dc](https://github.com/windmill-labs/windmill/commit/cedd6dc901918d3001229fa95a5fe86e369b7bc4)) +* judge IPv4 embedded in IPv6 and pin the object storage test connect ([#11389](https://github.com/windmill-labs/windmill/issues/11389)) ([ec6ec1b](https://github.com/windmill-labs/windmill/commit/ec6ec1b06febd235e8a0d1ae8f5175fd929b79e7)) +* keep smtp_clicktracking_off when syncing instance config ([#11372](https://github.com/windmill-labs/windmill/issues/11372)) ([60ef821](https://github.com/windmill-labs/windmill/commit/60ef82196e014d39b0f497a9b8a88048d6e9792b)) +* keep test panel controls off the args form in debug mode ([#11382](https://github.com/windmill-labs/windmill/issues/11382)) ([22b5a1c](https://github.com/windmill-labs/windmill/commit/22b5a1cd62a483ab42ce7192b3b8dabe7738b99e)) +* let custom workspace error handlers send email with the instance SMTP ([#11365](https://github.com/windmill-labs/windmill/issues/11365)) ([e2be584](https://github.com/windmill-labs/windmill/commit/e2be584ca51bfa7bbe927e7c81c5355817649e7b)) +* list only the paths the caller can read in path autocomplete ([#11388](https://github.com/windmill-labs/windmill/issues/11388)) ([f4dcaf3](https://github.com/windmill-labs/windmill/commit/f4dcaf3e45a90951b6c2da71ac0d7af51eb65d89)) +* never double-process a slow canceled flow in the zombie sweep ([#11368](https://github.com/windmill-labs/windmill/issues/11368)) ([d7a61de](https://github.com/windmill-labs/windmill/commit/d7a61de23f98a09c881121d9595ec3c11101d04f)) +* only let a job's own token claim run lineage ([#11367](https://github.com/windmill-labs/windmill/issues/11367)) ([90f9e59](https://github.com/windmill-labs/windmill/commit/90f9e59321a16ce65ae25b49bda7e632beb313e8)) +* only restart a flow on a version of its own path and workspace ([#11376](https://github.com/windmill-labs/windmill/issues/11376)) ([893e64f](https://github.com/windmill-labs/windmill/commit/893e64f63056ba42b0fe0bcf07c6078830d3669c)) +* redesign run not found page and fix switching to the right workspace ([#11374](https://github.com/windmill-labs/windmill/issues/11374)) ([d9c7d71](https://github.com/windmill-labs/windmill/commit/d9c7d71f072b0562bb3b1642cfdc961a8948671b)) +* run an AI agent tool on the worker its own tag selects ([#11370](https://github.com/windmill-labs/windmill/issues/11370)) ([649c43e](https://github.com/windmill-labs/windmill/commit/649c43e7c162ee47f4896330595647e73d4e8888)) +* scope flow resume to its workspace and minting to the job's run ([#11392](https://github.com/windmill-labs/windmill/issues/11392)) ([163a4ff](https://github.com/windmill-labs/windmill/commit/163a4ffa4e60b1eb312476da617665a71bdae040)) +* scope workspace dependencies create to the path workspace ([#11385](https://github.com/windmill-labs/windmill/issues/11385)) ([3eaf288](https://github.com/windmill-labs/windmill/commit/3eaf2888c002e7697b105fe56d8358e099809e8f)) +* stop the schedule enabled toggle from showing unsaved changes ([#11390](https://github.com/windmill-labs/windmill/issues/11390)) ([3838cd6](https://github.com/windmill-labs/windmill/commit/3838cd6ee08c16556abdf62d0bbf4c73542a6d9c)) + + +### Performance Improvements + +* advance a flow step with one v2_job_status update ([#11357](https://github.com/windmill-labs/windmill/issues/11357)) ([65cba2d](https://github.com/windmill-labs/windmill/commit/65cba2dbb7bf5de88ee474f3849eba3b1bcd1232)) +* complete a job in one statement on the common path ([#11355](https://github.com/windmill-labs/windmill/issues/11355)) ([bebd762](https://github.com/windmill-labs/windmill/commit/bebd762194b2db740d4989d8925e1efb5b348e08)) +* shrink the module graph that gates first paint in dev ([#11373](https://github.com/windmill-labs/windmill/issues/11373)) ([47525b2](https://github.com/windmill-labs/windmill/commit/47525b211a37c2edcccda558d3f2b2300b5cc280)) + +## [1.818.0](https://github.com/windmill-labs/windmill/compare/v1.817.0...v1.818.0) (2026-09-25) + + +### Features + +* add an instance-wide accent color setting with sidebar tint ([#11335](https://github.com/windmill-labs/windmill/issues/11335)) ([1fd729a](https://github.com/windmill-labs/windmill/commit/1fd729ac1aaec1e6a3af0d6db5fc1d6e1db6fa62)) +* add an options field to the postgresql resource ([#11223](https://github.com/windmill-labs/windmill/issues/11223)) ([d3d5392](https://github.com/windmill-labs/windmill/commit/d3d539291716baf5807a724d60d338cd3c4fe521)) +* **ai-chat:** show native script edit diffs ([#11278](https://github.com/windmill-labs/windmill/issues/11278)) ([1de54ee](https://github.com/windmill-labs/windmill/commit/1de54eeea4c4820e7291d9648a44ef3c86979c5a)) +* alert on and optionally cancel jobs stuck on unserved tags ([#11354](https://github.com/windmill-labs/windmill/issues/11354)) ([da866c5](https://github.com/windmill-labs/windmill/commit/da866c5eff77f66e45b8e3941166c695536762f5)) +* **bedrock:** add OIDC role assumption as a fourth auth mode ([#10936](https://github.com/windmill-labs/windmill/issues/10936)) ([e14da5c](https://github.com/windmill-labs/windmill/commit/e14da5c6bc8256bfe1d71ddecb886c3efc994ae4)) +* chat with a saved agent from the agent editor ([#11292](https://github.com/windmill-labs/windmill/issues/11292)) ([d8aaa73](https://github.com/windmill-labs/windmill/commit/d8aaa73573c46a8a23f6c6850d38438ad395752c)) +* **cli:** include extra paths in codebase digest for monorepo imports ([#11327](https://github.com/windmill-labs/windmill/issues/11327)) ([9b46b21](https://github.com/windmill-labs/windmill/commit/9b46b21f517a67867912092c619675d91a197313)) +* collapse the fork members setting and show its state in a badge ([#11220](https://github.com/windmill-labs/windmill/issues/11220)) ([3bd89e9](https://github.com/windmill-labs/windmill/commit/3bd89e92d8f563b564a3d2e619749f377a7d6982)) +* count enabled service accounts as operator seats ([#11297](https://github.com/windmill-labs/windmill/issues/11297)) ([318f899](https://github.com/windmill-labs/windmill/commit/318f89960e5594278d2aaad68f40db36b96918e7)) +* **db-manager:** add a schema diagram view ([#11030](https://github.com/windmill-labs/windmill/issues/11030)) ([7dec3d9](https://github.com/windmill-labs/windmill/commit/7dec3d97717406d078499ea6509cdb00c89c3480)) +* filter ai session tools to the user's workspace capabilities ([#10719](https://github.com/windmill-labs/windmill/issues/10719)) ([6cd70a8](https://github.com/windmill-labs/windmill/commit/6cd70a8e08cf4869cb52ef554bc89d5d13fadf5f)) +* infer a script's schema when a deploy (e.g. MCP) sends none ([#11339](https://github.com/windmill-labs/windmill/issues/11339)) ([d18d704](https://github.com/windmill-labs/windmill/commit/d18d7043df6536e10b22ee9f3df77e926a0460b4)) +* let a workspace withdraw operator schedule and trigger writes ([#11226](https://github.com/windmill-labs/windmill/issues/11226)) ([9a1c6e5](https://github.com/windmill-labs/windmill/commit/9a1c6e508127579dee4c1fae5c3257930e0b8aa1)) +* mount session list pages in process instead of iframes ([#11289](https://github.com/windmill-labs/windmill/issues/11289)) ([ebb3048](https://github.com/windmill-labs/windmill/commit/ebb3048ca0b6cb9f3c9130ac0871581f3c2ed307)) +* per-folder AI chat instructions via ai_instruction resources ([#11325](https://github.com/windmill-labs/windmill/issues/11325)) ([80903c9](https://github.com/windmill-labs/windmill/commit/80903c9a64125763e7514c09618e1def21a5bf97)) +* refresh MCP tools when scripts and flows change ([#11337](https://github.com/windmill-labs/windmill/issues/11337)) ([0bac766](https://github.com/windmill-labs/windmill/commit/0bac766756cf9ebcb615fe20d5a69c09c823c725)) +* render a tool's web search result as the session web search card ([#11313](https://github.com/windmill-labs/windmill/issues/11313)) ([fa0fc24](https://github.com/windmill-labs/windmill/commit/fa0fc242691ed3997c14e821b6030166223c0da0)) +* rework the db manager: native grid, tabs, sql editor, joined columns ([#11340](https://github.com/windmill-labs/windmill/issues/11340)) ([4fd7d62](https://github.com/windmill-labs/windmill/commit/4fd7d62bd0cf4970a6b155c4a3afac5fb1b79d16)) +* rework the git sync setup into a guided flow ([#11308](https://github.com/windmill-labs/windmill/issues/11308)) ([504d01a](https://github.com/windmill-labs/windmill/commit/504d01a5631e33b3a2604ef401ab8b73e4a55395)) +* schedule hub scripts ([#11330](https://github.com/windmill-labs/windmill/issues/11330)) ([733c119](https://github.com/windmill-labs/windmill/commit/733c119fd9464a951e157a8d18ec9d474430e910)) +* seed SCIM usernames from nickName ([#11346](https://github.com/windmill-labs/windmill/issues/11346)) ([9100ab9](https://github.com/windmill-labs/windmill/commit/9100ab954ab5b40cf6b9d7320e0fe435186f7eb4)) +* show restart from failed step in run page top bar ([#11317](https://github.com/windmill-labs/windmill/issues/11317)) ([62d0088](https://github.com/windmill-labs/windmill/commit/62d0088de5463d44d00a8c9204e03d20649acd07)) +* start a deferred queued job now without changing its id ([#11347](https://github.com/windmill-labs/windmill/issues/11347)) ([4b09558](https://github.com/windmill-labs/windmill/commit/4b09558e13fadca6fe604c231a5cdfb896d4d0f8)) +* support aiagent steps in test_run_step ([#11321](https://github.com/windmill-labs/windmill/issues/11321)) ([1b74939](https://github.com/windmill-labs/windmill/commit/1b749399521cc5ebaa07fc5a55773113a6a3b903)) + + +### Bug Fixes + +* always save slack/teams/email handlers as scripts ([#11345](https://github.com/windmill-labs/windmill/issues/11345)) ([421fdab](https://github.com/windmill-labs/windmill/commit/421fdab3d89fb0241a4266393d318a5f9be6ead9)) +* bill from a single service account telemetry count ([#11301](https://github.com/windmill-labs/windmill/issues/11301)) ([ceb1722](https://github.com/windmill-labs/windmill/commit/ceb17223dcb5f66532017036916e91a5323279fb)) +* build ag grid link cells as DOM nodes with a vetted href ([#11316](https://github.com/windmill-labs/windmill/issues/11316)) ([5b03c17](https://github.com/windmill-labs/windmill/commit/5b03c1732e905673480d0960b95577d47ec919b3)) +* carry labels when deploying variables, resources and folders ([#11222](https://github.com/windmill-labs/windmill/issues/11222)) ([94e4fb1](https://github.com/windmill-labs/windmill/commit/94e4fb1c842057e92f3775eac0826ac7e396d725)) +* centre the toggle knob inside its track ([#11314](https://github.com/windmill-labs/windmill/issues/11314)) ([d02ff9a](https://github.com/windmill-labs/windmill/commit/d02ff9ac24d6d720c478e8424e73eebae9ec98fe)) +* **cli:** stub the API client over its real exports in tests ([#11363](https://github.com/windmill-labs/windmill/issues/11363)) ([30bb62c](https://github.com/windmill-labs/windmill/commit/30bb62cd2516466936fc791f2ff89dd61c743dba)) +* correct the tool controls and name field of a nested AI agent ([#11221](https://github.com/windmill-labs/windmill/issues/11221)) ([1d119e6](https://github.com/windmill-labs/windmill/commit/1d119e6b25fd5c72776dfb49917387de4be065c1)) +* **frontend:** keep the session when the persisted workspace is stale ([#11344](https://github.com/windmill-labs/windmill/issues/11344)) ([2e5f6d0](https://github.com/windmill-labs/windmill/commit/2e5f6d0e76301d19d2017239f5ad4fe25d67dc57)) +* hide answer actions until the chat turn ends ([#11323](https://github.com/windmill-labs/windmill/issues/11323)) ([98d3897](https://github.com/windmill-labs/windmill/commit/98d3897b8c926cc6d47a2417b6943a5d5612b3aa)) +* keep a watched job's code when its completion update drops it ([#11290](https://github.com/windmill-labs/windmill/issues/11290)) ([592ef73](https://github.com/windmill-labs/windmill/commit/592ef736104ff63dd36978a69353a2585636da87)) +* label chat job links with the end of the job id ([#11324](https://github.com/windmill-labs/windmill/issues/11324)) ([2f2882a](https://github.com/windmill-labs/windmill/commit/2f2882adff8371e33c0fb72cbf0c61298bbcb2ca)) +* limit compare page guidance to fork deploys ([#11300](https://github.com/windmill-labs/windmill/issues/11300)) ([c232ea4](https://github.com/windmill-labs/windmill/commit/c232ea43b4bfe1ed295dd2f71cc6cefa47772bed)) +* memoize the resolved authed so one request resolves identity once ([#11299](https://github.com/windmill-labs/windmill/issues/11299)) ([42655ff](https://github.com/windmill-labs/windmill/commit/42655ff5ef3e4eae8ff90c623683bb321ae7d06b)) +* **multiplayer:** a malformed frame from one client no longer exits the server ([#11353](https://github.com/windmill-labs/windmill/issues/11353)) ([8caf414](https://github.com/windmill-labs/windmill/commit/8caf41430123318a73b76e4c202da07aecaa3c1e)) +* **multiplayer:** don't drop client messages during cold-start token verification ([#11343](https://github.com/windmill-labs/windmill/issues/11343)) ([e8c3f95](https://github.com/windmill-labs/windmill/commit/e8c3f9514e5f712a3d84e301771de748f1cc1fda)) +* pass the schedule's custom tag when using run now ([#11322](https://github.com/windmill-labs/windmill/issues/11322)) ([8525206](https://github.com/windmill-labs/windmill/commit/852520636160408cd16119184ffe9c7be92682c8)) +* relock importers on their own tag, not the bare dependency tag ([#11359](https://github.com/windmill-labs/windmill/issues/11359)) ([a1abb36](https://github.com/windmill-labs/windmill/commit/a1abb36d9f5408b5fa3b659b769bc198604804ce)) +* space chat rows evenly after thinking and answers ([#11315](https://github.com/windmill-labs/windmill/issues/11315)) ([0e53d53](https://github.com/windmill-labs/windmill/commit/0e53d53b07ee1d4611c0f5fb605a290f596de980)) +* stop the chat repeating run results the card already shows ([#11293](https://github.com/windmill-labs/windmill/issues/11293)) ([ea0b947](https://github.com/windmill-labs/windmill/commit/ea0b947644d4094304ecea26406fa6aac15dcfac)) +* trust the system CA store for SMTP TLS ([#11328](https://github.com/windmill-labs/windmill/issues/11328)) ([9375c93](https://github.com/windmill-labs/windmill/commit/9375c93fd8d55bf22e3f290f44650affdf1868c2)) +* validate app and trigger paths, refuse traversal in workspace export ([#11311](https://github.com/windmill-labs/windmill/issues/11311)) ([7593597](https://github.com/windmill-labs/windmill/commit/7593597617110fbd276f3f32ca2be7064547a080)) + + +### Performance Improvements + +* skip job-start pings and checkpoint read for short non-WAC jobs ([#11356](https://github.com/windmill-labs/windmill/issues/11356)) ([53a5cfd](https://github.com/windmill-labs/windmill/commit/53a5cfd17ad4d6f378450f7e502539c5ad8c445d)) +* skip parent status write when a parallel loop iteration starts ([#11348](https://github.com/windmill-labs/windmill/issues/11348)) ([2456282](https://github.com/windmill-labs/windmill/commit/245628210f7d75aedb9e0a289d1dee63f1513b5b)) +* skip the flow_env ancestor walk for sub-flows with nothing to inherit ([#11349](https://github.com/windmill-labs/windmill/issues/11349)) ([bc4f872](https://github.com/windmill-labs/windmill/commit/bc4f872f1093a59b9b8fbc3e11a6f00b42474f0c)) +* stop polling http trigger routes on workers and every minute ([#11351](https://github.com/windmill-labs/windmill/issues/11351)) ([d931032](https://github.com/windmill-labs/windmill/commit/d9310327063b8b51ab3fe8d27cbdab99f5552967)) + ## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22) diff --git a/backend/.sqlx/query-042ff3003bf82d11a78a1074a63379c19880e4d66dbf4dd5391f053b6dfa6b01.json b/backend/.sqlx/query-042ff3003bf82d11a78a1074a63379c19880e4d66dbf4dd5391f053b6dfa6b01.json deleted file mode 100644 index fef2943c22..0000000000 --- a/backend/.sqlx/query-042ff3003bf82d11a78a1074a63379c19880e4d66dbf4dd5391f053b6dfa6b01.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT q.workspace_id, q.id, started_at, COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000), $3::text::jsonb, $10, $5, $6,\n flow_status, workflow_as_code_status,\n $8, CASE WHEN $4::BOOL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END AS status,\n q.worker\n FROM v2_job_queue q LEFT JOIN v2_job_status USING (id) WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb RETURNING duration_ms AS \"duration_ms!\"", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "duration_ms!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Bool", - "Text", - "Bool", - "Varchar", - "Text", - "Bool", - "Int4", - "Int8", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "042ff3003bf82d11a78a1074a63379c19880e4d66dbf4dd5391f053b6dfa6b01" -} diff --git a/backend/.sqlx/query-06db0e720dd59a7c52c0a98ea7b316237eb1a547678858c1a1e45985035b3468.json b/backend/.sqlx/query-06db0e720dd59a7c52c0a98ea7b316237eb1a547678858c1a1e45985035b3468.json deleted file mode 100644 index aea8b8302b..0000000000 --- a/backend/.sqlx/query-06db0e720dd59a7c52c0a98ea7b316237eb1a547678858c1a1e45985035b3468.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_status = flow_status - 'retry'\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "06db0e720dd59a7c52c0a98ea7b316237eb1a547678858c1a1e45985035b3468" -} diff --git a/backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json b/backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json similarity index 74% rename from backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json rename to backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json index 0d8d788602..54d1b5757c 100644 --- a/backend/.sqlx/query-8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8.json +++ b/backend/.sqlx/query-0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1)", + "query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2", "describe": { "columns": [ { @@ -16,7 +16,8 @@ ], "parameters": { "Left": [ - "UuidArray" + "UuidArray", + "Text" ] }, "nullable": [ @@ -24,5 +25,5 @@ true ] }, - "hash": "8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8" + "hash": "0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7" } diff --git a/backend/.sqlx/query-0ad65482d8282f778f4123b4133f9f230fffd4ea1f403dd37b28d4a105c7ef58.json b/backend/.sqlx/query-0ad65482d8282f778f4123b4133f9f230fffd4ea1f403dd37b28d4a105c7ef58.json new file mode 100644 index 0000000000..a6bf4b92d5 --- /dev/null +++ b/backend/.sqlx/query-0ad65482d8282f778f4123b4133f9f230fffd4ea1f403dd37b28d4a105c7ef58.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue SET running = false,\n started_at = CASE WHEN canceled_by IS NULL THEN NULL ELSE started_at END\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0ad65482d8282f778f4123b4133f9f230fffd4ea1f403dd37b28d4a105c7ef58" +} diff --git a/backend/.sqlx/query-0da220d34f1dda6409c58a77767270986cf666543cad9204c286d5142a72962a.json b/backend/.sqlx/query-0da220d34f1dda6409c58a77767270986cf666543cad9204c286d5142a72962a.json new file mode 100644 index 0000000000..708f49cfaa --- /dev/null +++ b/backend/.sqlx/query-0da220d34f1dda6409c58a77767270986cf666543cad9204c286d5142a72962a.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH served AS (\n SELECT unnest(custom_tags) AS tag FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second'\n UNION SELECT regexp_replace(worker, '-[^-]*$', '') FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second'\n UNION SELECT p.key\n FROM worker_ping w JOIN config c ON c.name = 'worker__' || w.worker_group,\n jsonb_each(c.config->'priority_tags') p\n WHERE w.ping_at > now() - $1::bigint * interval '1 second'\n AND jsonb_typeof(c.config->'priority_tags') = 'object'\n AND jsonb_typeof(p.value) = 'number' AND p.value::text::numeric > 0\n ),\n pending AS MATERIALIZED (\n SELECT q.id, q.workspace_id, q.tag, q.scheduled_for FROM v2_job_queue q\n WHERE q.running = false\n AND q.canceled_by IS NULL\n AND q.scheduled_for <= now() - $1::bigint * interval '1 second'\n AND NOT EXISTS (SELECT 1 FROM served s WHERE s.tag = q.tag)\n )\n SELECT p.workspace_id AS \"workspace_id!\", p.tag AS \"tag!\", count(*) AS \"count!\",\n min(p.scheduled_for) AS \"oldest!\"\n FROM pending p JOIN v2_job j ON j.id = p.id\n WHERE (j.parent_job IS NULL\n OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))\n GROUP BY p.workspace_id, p.tag\n ORDER BY min(p.scheduled_for)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "tag!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "count!", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "oldest!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + null, + null + ] + }, + "hash": "0da220d34f1dda6409c58a77767270986cf666543cad9204c286d5142a72962a" +} diff --git a/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json b/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json similarity index 53% rename from backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json rename to backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json index a40cc12148..bc821f2b7b 100644 --- a/backend/.sqlx/query-3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb.json +++ b/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ", + "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ", "describe": { "columns": [ { @@ -41,7 +41,8 @@ ], "parameters": { "Left": [ - "Uuid" + "Uuid", + "Text" ] }, "nullable": [ @@ -54,5 +55,5 @@ null ] }, - "hash": "3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb" + "hash": "11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092" } diff --git a/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json new file mode 100644 index 0000000000..46b7d2509c --- /dev/null +++ b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json @@ -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" +} diff --git a/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json b/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json new file mode 100644 index 0000000000..2a8d0ab26f --- /dev/null +++ b/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind,\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n -- An app editor preview also runs an app script, at an app path it does not\n -- have to own; only deployed-app runs are stamped with their app.\n WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'\n AND starts_with(runnable_path, trigger || '/'), false)\n ELSE true\n END AS \"origin_verified!\"\n FROM lineage ORDER BY depth", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "trigger_kind", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "origin_verified!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null + ] + }, + "hash": "201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655" +} diff --git a/backend/.sqlx/query-253ea6abfee1824f8b2863a6a7eb7dd8c42952216db9ebad6903d92f06e29b8f.json b/backend/.sqlx/query-253ea6abfee1824f8b2863a6a7eb7dd8c42952216db9ebad6903d92f06e29b8f.json new file mode 100644 index 0000000000..a975496a25 --- /dev/null +++ b/backend/.sqlx/query-253ea6abfee1824f8b2863a6a7eb7dd8c42952216db9ebad6903d92f06e29b8f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second') AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "253ea6abfee1824f8b2863a6a7eb7dd8c42952216db9ebad6903d92f06e29b8f" +} diff --git a/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json b/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json new file mode 100644 index 0000000000..d19056a19a --- /dev/null +++ b/backend/.sqlx/query-2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b" +} diff --git a/backend/.sqlx/query-2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f.json b/backend/.sqlx/query-2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f.json deleted file mode 100644 index 3a864fee63..0000000000 --- a/backend/.sqlx/query-2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT q.workspace_id, q.id, q.started_at,\n COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,\n $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb\n RETURNING duration_ms AS \"duration_ms!\"", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "duration_ms!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Jsonb" - ] - }, - "nullable": [ - false - ] - }, - "hash": "2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f" -} diff --git a/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json b/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json new file mode 100644 index 0000000000..13a8e589b4 --- /dev/null +++ b/backend/.sqlx/query-2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_job, root_job, flow_innermost_root_job FROM v2_job\n WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c" +} diff --git a/backend/.sqlx/query-2b8eef255c6c76d098c4e4f6b0dd1a74cdad79632ac6e4a0ce7715616b630083.json b/backend/.sqlx/query-2b8eef255c6c76d098c4e4f6b0dd1a74cdad79632ac6e4a0ce7715616b630083.json new file mode 100644 index 0000000000..d9f9af5442 --- /dev/null +++ b/backend/.sqlx/query-2b8eef255c6c76d098c4e4f6b0dd1a74cdad79632ac6e4a0ce7715616b630083.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM v2_job_queue WHERE id = $1 AND running = false FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2b8eef255c6c76d098c4e4f6b0dd1a74cdad79632ac6e4a0ce7715616b630083" +} diff --git a/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json b/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json new file mode 100644 index 0000000000..88b7f6411b --- /dev/null +++ b/backend/.sqlx/query-2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM capture WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7" +} diff --git a/backend/.sqlx/query-33cc2b020be2868a731ef0e09d0d4dc9792557621269e11ae76009d0c25f98f3.json b/backend/.sqlx/query-33cc2b020be2868a731ef0e09d0d4dc9792557621269e11ae76009d0c25f98f3.json new file mode 100644 index 0000000000..c9b1a6d995 --- /dev/null +++ b/backend/.sqlx/query-33cc2b020be2868a731ef0e09d0d4dc9792557621269e11ae76009d0c25f98f3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue q SET canceled_by = 'monitor', canceled_reason = $3\n WHERE q.id = $1 AND q.running = false AND q.canceled_by IS NULL\n AND NOT EXISTS (SELECT 1 FROM worker_ping w\n LEFT JOIN config c ON c.name = 'worker__' || w.worker_group\n WHERE w.ping_at > now() - $2::bigint * interval '1 second'\n AND (q.tag = ANY(w.custom_tags)\n OR q.tag = regexp_replace(w.worker, '-[^-]*$', '')\n OR (jsonb_typeof(c.config->'priority_tags'->q.tag) = 'number'\n AND (c.config->'priority_tags'->>q.tag)::numeric > 0)))\n RETURNING q.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "33cc2b020be2868a731ef0e09d0d4dc9792557621269e11ae76009d0c25f98f3" +} diff --git a/backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json similarity index 62% rename from backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json rename to backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json index f787574b3a..f5a2ac20ed 100644 --- a/backend/.sqlx/query-f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7.json +++ b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7" + "hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11" } diff --git a/backend/.sqlx/query-0aaec91ab06753e46c595d82469924a98f28b0dead245df7248a9ccb8a5f20c3.json b/backend/.sqlx/query-3c74bcf357bb87e2ee960473d40949b5bacac241549614bd39e4b055db7fc1ea.json similarity index 56% rename from backend/.sqlx/query-0aaec91ab06753e46c595d82469924a98f28b0dead245df7248a9ccb8a5f20c3.json rename to backend/.sqlx/query-3c74bcf357bb87e2ee960473d40949b5bacac241549614bd39e4b055db7fc1ea.json index 04f5ba5b84..aae7f79b15 100644 --- a/backend/.sqlx/query-0aaec91ab06753e46c595d82469924a98f28b0dead245df7248a9ccb8a5f20c3.json +++ b/backend/.sqlx/query-3c74bcf357bb87e2ee960473d40949b5bacac241549614bd39e4b055db7fc1ea.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', $1::TEXT], $2)\n WHERE id = $3", + "query": "UPDATE v2_job_status\n SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2)\n WHERE id = $3", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "0aaec91ab06753e46c595d82469924a98f28b0dead245df7248a9ccb8a5f20c3" + "hash": "3c74bcf357bb87e2ee960473d40949b5bacac241549614bd39e4b055db7fc1ea" } diff --git a/backend/.sqlx/query-3e55d027327bd3c76810fbe22d3ccb1bbbf83c8cff69d8f5907d1417a2522e69.json b/backend/.sqlx/query-3e55d027327bd3c76810fbe22d3ccb1bbbf83c8cff69d8f5907d1417a2522e69.json deleted file mode 100644 index 2603a95877..0000000000 --- a/backend/.sqlx/query-3e55d027327bd3c76810fbe22d3ccb1bbbf83c8cff69d8f5907d1417a2522e69.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_queue SET running = false, started_at = null\n WHERE id = $1 AND canceled_by IS NULL", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "3e55d027327bd3c76810fbe22d3ccb1bbbf83c8cff69d8f5907d1417a2522e69" -} diff --git a/backend/.sqlx/query-4461fe84370e7f07b4423ea5e71b913dd6ee9c655f9ec7087cc0fec9b9c3099a.json b/backend/.sqlx/query-4461fe84370e7f07b4423ea5e71b913dd6ee9c655f9ec7087cc0fec9b9c3099a.json deleted file mode 100644 index eed1ff9f3f..0000000000 --- a/backend/.sqlx/query-4461fe84370e7f07b4423ea5e71b913dd6ee9c655f9ec7087cc0fec9b9c3099a.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH step_index AS (\n SELECT idx::text AS idx\n FROM v2_job_status,\n jsonb_array_elements(flow_status->'modules') WITH ORDINALITY arr(elem, idx)\n WHERE id = $1\n AND elem->>'id' = $5\n LIMIT 1\n ), completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n $3::text::jsonb,\n CASE WHEN si.idx IS NOT NULL\n THEN jsonb_set(\n s.flow_status,\n ARRAY['modules', (si.idx::int - 1)::text],\n $6::jsonb\n )\n ELSE s.flow_status\n END,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n LEFT JOIN step_index si ON true\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($4, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "x", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Text", - "Text", - "Text", - "Jsonb" - ] - }, - "nullable": [ - null - ] - }, - "hash": "4461fe84370e7f07b4423ea5e71b913dd6ee9c655f9ec7087cc0fec9b9c3099a" -} diff --git a/backend/.sqlx/query-4515bdeded3b40abbdcc554c9562cce3225d2b66dc9fe23b1fa59cfaa120cd30.json b/backend/.sqlx/query-4515bdeded3b40abbdcc554c9562cce3225d2b66dc9fe23b1fa59cfaa120cd30.json new file mode 100644 index 0000000000..b456310822 --- /dev/null +++ b/backend/.sqlx/query-4515bdeded3b40abbdcc554c9562cce3225d2b66dc9fe23b1fa59cfaa120cd30.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4515bdeded3b40abbdcc554c9562cce3225d2b66dc9fe23b1fa59cfaa120cd30" +} diff --git a/backend/.sqlx/query-457575e0858db5747964d4b2a69d3506774e50f871963da9697c7035def2aedd.json b/backend/.sqlx/query-457575e0858db5747964d4b2a69d3506774e50f871963da9697c7035def2aedd.json new file mode 100644 index 0000000000..dfff54ec4b --- /dev/null +++ b/backend/.sqlx/query-457575e0858db5747964d4b2a69d3506774e50f871963da9697c7035def2aedd.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "schedules!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "triggers!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "457575e0858db5747964d4b2a69d3506774e50f871963da9697c7035def2aedd" +} diff --git a/backend/.sqlx/query-4622d28e2fa09bc60b9d0c79397efe0ca030638ded82c2ffd2155cacdf36ec11.json b/backend/.sqlx/query-4622d28e2fa09bc60b9d0c79397efe0ca030638ded82c2ffd2155cacdf36ec11.json deleted file mode 100644 index f30ab8d49d..0000000000 --- a/backend/.sqlx/query-4622d28e2fa09bc60b9d0c79397efe0ca030638ded82c2ffd2155cacdf36ec11.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1)\n WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "4622d28e2fa09bc60b9d0c79397efe0ca030638ded82c2ffd2155cacdf36ec11" -} diff --git a/backend/.sqlx/query-499dc116f9916af49322461285f698e7fd3d3185206ae48f72445ce78299d9b1.json b/backend/.sqlx/query-499dc116f9916af49322461285f698e7fd3d3185206ae48f72445ce78299d9b1.json new file mode 100644 index 0000000000..41d3453857 --- /dev/null +++ b/backend/.sqlx/query-499dc116f9916af49322461285f698e7fd3d3185206ae48f72445ce78299d9b1.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH pending AS MATERIALIZED (\n SELECT q.id, q.scheduled_for FROM v2_job_queue q\n WHERE q.workspace_id = $1 AND q.tag = $2\n AND q.running = false\n AND q.canceled_by IS NULL\n AND q.scheduled_for <= now() - $3::bigint * interval '1 second'\n )\n SELECT p.id FROM pending p JOIN v2_job j ON j.id = p.id\n WHERE (j.parent_job IS NULL\n OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))\n ORDER BY p.scheduled_for\n LIMIT $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "499dc116f9916af49322461285f698e7fd3d3185206ae48f72445ce78299d9b1" +} diff --git a/backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json b/backend/.sqlx/query-4f0b93160853a64c618872abbe934e4bcee02d8a9101cc7c1518ca73459c1fca.json similarity index 72% rename from backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json rename to backend/.sqlx/query-4f0b93160853a64c618872abbe934e4bcee02d8a9101cc7c1518ca73459c1fca.json index 1ee774a993..d178664678 100644 --- a/backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json +++ b/backend/.sqlx/query-4f0b93160853a64c618872abbe934e4bcee02d8a9101cc7c1518ca73459c1fca.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status)::text AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ", + "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status)::text AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\",\n q.canceled_by AS \"canceled_by?\", q.canceled_reason AS \"canceled_reason?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND (q.canceled_by IS NULL OR r.ping < NOW() - ($1 || ' seconds')::interval * 10)\n\n ", "describe": { "columns": [ { @@ -82,6 +82,16 @@ "ordinal": 15, "name": "worker_instance?", "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "canceled_by?", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "canceled_reason?", + "type_info": "Text" } ], "parameters": { @@ -105,8 +115,10 @@ false, false, true, - false + false, + true, + true ] }, - "hash": "1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc" + "hash": "4f0b93160853a64c618872abbe934e4bcee02d8a9101cc7c1518ca73459c1fca" } diff --git a/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json b/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json new file mode 100644 index 0000000000..cc6a57c005 --- /dev/null +++ b/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.id IS NOT NULL AS \"completed!\",\n c.result AS \"result: sqlx::types::Json>\",\n c.status = 'success' AS \"success\",\n EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\"\n FROM (SELECT 1) one\n LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "completed!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "result: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "success", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "queued!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + true, + null, + null + ] + }, + "hash": "52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5" +} diff --git a/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json b/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json new file mode 100644 index 0000000000..ee7f603da1 --- /dev/null +++ b/backend/.sqlx/query-535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n n.created_by,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "kind!: _", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "script_path!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "script_hash!: _", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "scheduled_for!: _", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "input", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "schema: _", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false, + null, + null, + null, + null, + true, + false, + null + ] + }, + "hash": "535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a" +} diff --git a/backend/.sqlx/query-538f12535bd1b93a3a9baac91dc0d4ee0f3c949a00f62d38686e75d22e9d7884.json b/backend/.sqlx/query-538f12535bd1b93a3a9baac91dc0d4ee0f3c949a00f62d38686e75d22e9d7884.json new file mode 100644 index 0000000000..1316e1d1dc --- /dev/null +++ b/backend/.sqlx/query-538f12535bd1b93a3a9baac91dc0d4ee0f3c949a00f62d38686e75d22e9d7884.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(c.duration_ms, COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint) AS \"duration_ms!\"\n FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "duration_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "538f12535bd1b93a3a9baac91dc0d4ee0f3c949a00f62d38686e75d22e9d7884" +} diff --git a/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json b/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json deleted file mode 100644 index 57989bb6de..0000000000 --- a/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3" -} diff --git a/backend/.sqlx/query-5ffba9baef525e1e46e9a258e39614fe8c00a264667ce48fb1f0b45662cef43d.json b/backend/.sqlx/query-5ffba9baef525e1e46e9a258e39614fe8c00a264667ce48fb1f0b45662cef43d.json new file mode 100644 index 0000000000..8381e1ca55 --- /dev/null +++ b/backend/.sqlx/query-5ffba9baef525e1e46e9a258e39614fe8c00a264667ce48fb1f0b45662cef43d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status\n SET flow_status = flow_status - 'retry'\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5ffba9baef525e1e46e9a258e39614fe8c00a264667ce48fb1f0b45662cef43d" +} diff --git a/backend/.sqlx/query-623c3d92f4398ba4f88d1b4178134498bfa578b1fd459e1f730a2e06688aa142.json b/backend/.sqlx/query-623c3d92f4398ba4f88d1b4178134498bfa578b1fd459e1f730a2e06688aa142.json new file mode 100644 index 0000000000..d3084085d8 --- /dev/null +++ b/backend/.sqlx/query-623c3d92f4398ba4f88d1b4178134498bfa578b1fd459e1f730a2e06688aa142.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, canceled_reason AS \"reason!\" FROM v2_job_queue\n WHERE running = false AND canceled_by = 'monitor' AND canceled_reason LIKE $1 || '%'\n LIMIT $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "reason!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "623c3d92f4398ba4f88d1b4178134498bfa578b1fd459e1f730a2e06688aa142" +} diff --git a/backend/.sqlx/query-67bde191639648d1f225a046eac57f4055900fdf0b1f65979deb1c1c64092a8e.json b/backend/.sqlx/query-67bde191639648d1f225a046eac57f4055900fdf0b1f65979deb1c1c64092a8e.json new file mode 100644 index 0000000000..4f1563f01c --- /dev/null +++ b/backend/.sqlx/query-67bde191639648d1f225a046eac57f4055900fdf0b1f65979deb1c1c64092a8e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(c.duration_ms, COALESCE($2::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(q.started_at, now()))))*1000)::bigint) AS \"duration_ms!\"\n FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "duration_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "67bde191639648d1f225a046eac57f4055900fdf0b1f65979deb1c1c64092a8e" +} diff --git a/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json b/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json deleted file mode 100644 index e8f746ded0..0000000000 --- a/backend/.sqlx/query-6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "kind!: _", - "type_info": { - "Custom": { - "name": "job_kind", - "kind": { - "Enum": [ - "script", - "preview", - "flow", - "dependencies", - "flowpreview", - "script_hub", - "identity", - "flowdependencies", - "http", - "graphql", - "postgresql", - "noop", - "appdependencies", - "deploymentcallback", - "singlestepflow", - "flowscript", - "flownode", - "appscript", - "aiagent", - "unassigned_script", - "unassigned_flow", - "unassigned_singlestepflow" - ] - } - } - } - }, - { - "ordinal": 2, - "name": "script_path!", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "script_hash!: _", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "scheduled_for!: _", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "input", - "type_info": "Jsonb" - }, - { - "ordinal": 6, - "name": "schema: _", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "Text" - ] - }, - "nullable": [ - false, - null, - null, - null, - null, - true, - null - ] - }, - "hash": "6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada" -} diff --git a/backend/.sqlx/query-7d07a717533bfcaf581f6655bc387095542490fbb4aae30ec7fa75c2dae98ec8.json b/backend/.sqlx/query-7d07a717533bfcaf581f6655bc387095542490fbb4aae30ec7fa75c2dae98ec8.json deleted file mode 100644 index 4e90a2a0ec..0000000000 --- a/backend/.sqlx/query-7d07a717533bfcaf581f6655bc387095542490fbb4aae30ec7fa75c2dae98ec8.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_runtime r SET\n ping = now()\n FROM v2_job_queue q\n WHERE r.id = $1 AND q.id = r.id\n AND q.workspace_id = $2\n AND canceled_by IS NULL", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "7d07a717533bfcaf581f6655bc387095542490fbb4aae30ec7fa75c2dae98ec8" -} diff --git a/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json b/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json new file mode 100644 index 0000000000..f105dd7825 --- /dev/null +++ b/backend/.sqlx/query-9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "duration_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text", + "Bool", + "Varchar", + "Text", + "Bool", + "Int4", + "Int8", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d" +} diff --git a/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json b/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json new file mode 100644 index 0000000000..0b166b32a4 --- /dev/null +++ b/backend/.sqlx/query-975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "duration_ms!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Text", + "Bool", + "Varchar", + "Text", + "Bool", + "Int4", + "Int8", + "TextArray", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5" +} diff --git a/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json deleted file mode 100644 index 0bc09d9430..0000000000 --- a/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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" -} diff --git a/backend/.sqlx/query-9d8e3b978d746432d336900282602f0ce092d50783d8b3ea17b7e6121f207b92.json b/backend/.sqlx/query-9d8e3b978d746432d336900282602f0ce092d50783d8b3ea17b7e6121f207b92.json new file mode 100644 index 0000000000..6d54b3c1c3 --- /dev/null +++ b/backend/.sqlx/query-9d8e3b978d746432d336900282602f0ce092d50783d8b3ea17b7e6121f207b92.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, 1)\n ON CONFLICT (job_id) DO UPDATE SET counter = zombie_job_counter.counter + 1\n RETURNING counter", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "counter", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9d8e3b978d746432d336900282602f0ce092d50783d8b3ea17b7e6121f207b92" +} diff --git a/backend/.sqlx/query-9fb1a1e4e39e0e8a0934fcda7b3e6e6180a48ab792d305d04146e81b06b0d69b.json b/backend/.sqlx/query-9fb1a1e4e39e0e8a0934fcda7b3e6e6180a48ab792d305d04146e81b06b0d69b.json new file mode 100644 index 0000000000..6ae18315bf --- /dev/null +++ b/backend/.sqlx/query-9fb1a1e4e39e0e8a0934fcda7b3e6e6180a48ab792d305d04146e81b06b0d69b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1)\n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9fb1a1e4e39e0e8a0934fcda7b3e6e6180a48ab792d305d04146e81b06b0d69b" +} diff --git a/backend/.sqlx/query-ab05c1359039ae99a2aa6156eb2a208814ce37fcde72e8ef61904a4f41fb4579.json b/backend/.sqlx/query-ab05c1359039ae99a2aa6156eb2a208814ce37fcde72e8ef61904a4f41fb4579.json new file mode 100644 index 0000000000..bb358546ac --- /dev/null +++ b/backend/.sqlx/query-ab05c1359039ae99a2aa6156eb2a208814ce37fcde72e8ef61904a4f41fb4579.json @@ -0,0 +1,325 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null + ] + }, + "hash": "ab05c1359039ae99a2aa6156eb2a208814ce37fcde72e8ef61904a4f41fb4579" +} diff --git a/backend/.sqlx/query-aed8bd751c3e988f422216e74acfb77dc03469355d2a0da0b2d6b4aeeea37d3e.json b/backend/.sqlx/query-aed8bd751c3e988f422216e74acfb77dc03469355d2a0da0b2d6b4aeeea37d3e.json deleted file mode 100644 index d8f93f74c4..0000000000 --- a/backend/.sqlx/query-aed8bd751c3e988f422216e74acfb77dc03469355d2a0da0b2d6b4aeeea37d3e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2)\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $3), $3) = id", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "aed8bd751c3e988f422216e74acfb77dc03469355d2a0da0b2d6b4aeeea37d3e" -} diff --git a/backend/.sqlx/query-bf2a8883c3a9d57ac56b347dccd26eaa4d92e36965e7f069a0107647a2bfff8b.json b/backend/.sqlx/query-bf2a8883c3a9d57ac56b347dccd26eaa4d92e36965e7f069a0107647a2bfff8b.json new file mode 100644 index 0000000000..36c2e6b937 --- /dev/null +++ b/backend/.sqlx/query-bf2a8883c3a9d57ac56b347dccd26eaa4d92e36965e7f069a0107647a2bfff8b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.id FROM v2_job_queue q JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1 AND q.running AND r.ping = $2\n FOR UPDATE OF q SKIP LOCKED", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "bf2a8883c3a9d57ac56b347dccd26eaa4d92e36965e7f069a0107647a2bfff8b" +} diff --git a/backend/.sqlx/query-c4254f50fc6f74aae9e23b461a6c69c181eba0210cc068b64e67b0a518fe1cba.json b/backend/.sqlx/query-c4254f50fc6f74aae9e23b461a6c69c181eba0210cc068b64e67b0a518fe1cba.json new file mode 100644 index 0000000000..588e6a7bc9 --- /dev/null +++ b/backend/.sqlx/query-c4254f50fc6f74aae9e23b461a6c69c181eba0210cc068b64e67b0a518fe1cba.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH step_index AS (\n SELECT idx::text AS idx\n FROM v2_job_status,\n jsonb_array_elements(flow_status->'modules') WITH ORDINALITY arr(elem, idx)\n WHERE id = $1\n AND elem->>'id' = $5\n LIMIT 1\n ), target AS (\n -- The queue row is locked before the completed row's key, as every completion does.\n SELECT id FROM v2_job_queue WHERE id = $1 FOR UPDATE\n ), completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n $3::text::jsonb,\n CASE WHEN si.idx IS NOT NULL\n THEN jsonb_set(\n s.flow_status,\n ARRAY['modules', (si.idx::int - 1)::text],\n $6::jsonb\n )\n ELSE s.flow_status\n END,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n LEFT JOIN step_index si ON true\n WHERE q.id IN (SELECT id FROM target)\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id IN (SELECT id FROM target)\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($4, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "x", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c4254f50fc6f74aae9e23b461a6c69c181eba0210cc068b64e67b0a518fe1cba" +} diff --git a/backend/.sqlx/query-c538f81b9622356edecea467f72d4dd24d13b3ddb380634e6ed4a3af56090b78.json b/backend/.sqlx/query-c538f81b9622356edecea467f72d4dd24d13b3ddb380634e6ed4a3af56090b78.json new file mode 100644 index 0000000000..a34697cb64 --- /dev/null +++ b/backend/.sqlx/query-c538f81b9622356edecea467f72d4dd24d13b3ddb380634e6ed4a3af56090b78.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM alerts\n WHERE alert_type = 'critical_error' AND resource = $1\n AND created_at > now() - $2::bigint * interval '1 second') AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c538f81b9622356edecea467f72d4dd24d13b3ddb380634e6ed4a3af56090b78" +} diff --git a/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json new file mode 100644 index 0000000000..4ed2afc15b --- /dev/null +++ b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json @@ -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" +} diff --git a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json similarity index 60% rename from backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json rename to backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json index 3d61a47eb1..f631078e76 100644 --- a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json +++ b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json @@ -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" } diff --git a/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json b/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json new file mode 100644 index 0000000000..02e74bd0fb --- /dev/null +++ b/backend/.sqlx/query-cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2 AND path = $3) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95" +} diff --git a/backend/.sqlx/query-d9b7e45213aebced9cebe8ce1278d38136bc73c3517d0e0fbad369666504930c.json b/backend/.sqlx/query-d9b7e45213aebced9cebe8ce1278d38136bc73c3517d0e0fbad369666504930c.json new file mode 100644 index 0000000000..12c3d52d01 --- /dev/null +++ b/backend/.sqlx/query-d9b7e45213aebced9cebe8ce1278d38136bc73c3517d0e0fbad369666504930c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id, started_at, worker\n )\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT d.workspace_id, d.id, d.started_at, $3, $2::jsonb, r.memory_peak,\n 'failure'::job_status, d.worker\n FROM deleted d\n LEFT JOIN v2_job_runtime r ON r.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Jsonb", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d9b7e45213aebced9cebe8ce1278d38136bc73c3517d0e0fbad369666504930c" +} diff --git a/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json deleted file mode 100644 index b11e41fe89..0000000000 --- a/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 57b15bc5a7..9182843789 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -536,7 +536,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -781,7 +781,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tokio-websockets", "tracing", @@ -1353,7 +1353,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower 0.5.3", "tracing", ] @@ -1703,9 +1703,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -1942,7 +1942,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tower-service", @@ -2230,7 +2230,7 @@ dependencies = [ "rand_distr", "rayon", "safetensors", - "thiserror 2.0.20", + "thiserror 2.0.21", "yoke", "zip", ] @@ -2248,7 +2248,7 @@ dependencies = [ "rayon", "safetensors", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2316,9 +2316,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -3784,7 +3784,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3824,7 +3824,7 @@ dependencies = [ "swc_sourcemap", "swc_visit", "text_lines", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-width 0.2.2", "url", ] @@ -3869,7 +3869,7 @@ dependencies = [ "smallvec", "sourcemap", "static_assertions", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "url", "v8", @@ -3916,7 +3916,7 @@ dependencies = [ "sha2 0.10.9", "signature", "spki", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "uuid", "x25519-dalek", @@ -4008,9 +4008,9 @@ dependencies = [ "rustls-webpki 0.102.8", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-socks", "tokio-util", "tokio-vsock", @@ -4040,7 +4040,7 @@ dependencies = [ "rand 0.8.5", "rayon", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "winapi", "windows-sys 0.59.0", ] @@ -4114,7 +4114,7 @@ dependencies = [ "serde", "sha2 0.10.9", "socket2 0.5.10", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-vsock", "url", @@ -4135,7 +4135,7 @@ dependencies = [ "strum", "strum_macros", "syn 2.0.119", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4147,7 +4147,7 @@ dependencies = [ "deno_error 0.6.1", "percent-encoding", "sys_traits", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", ] @@ -4174,7 +4174,7 @@ dependencies = [ "serde_json", "sys_traits", "temp_deno_which", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "winapi", "windows-sys 0.59.0", @@ -4215,7 +4215,7 @@ dependencies = [ "opentelemetry_sdk 0.27.1", "pin-project", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] @@ -4243,7 +4243,7 @@ dependencies = [ "rustls-tokio-stream", "rustls-webpki 0.102.8", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "webpki-roots 0.26.11", ] @@ -4286,7 +4286,7 @@ dependencies = [ "flate2", "futures", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "uuid", ] @@ -5060,9 +5060,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -5182,9 +5182,9 @@ dependencies = [ [[package]] name = "frostem" -version = "1.20260821.6" +version = "1.20260821.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d51501290db793146d5005edc29d3feac221da5702a4c63436344841ea88d4" +checksum = "28c70238056ad1dbfdf035222a571d2e9baf5b6a4c308cfa87bc8cbe7f2f3840" [[package]] name = "fs3" @@ -5766,7 +5766,7 @@ checksum = "28f4ed3cbcb66ddad22553bb3b94087573cf492a34501bc025a23167ed57e856" dependencies = [ "anyhow", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "unic-ucd-category", ] @@ -5892,8 +5892,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.2.0", ] @@ -5920,9 +5918,9 @@ dependencies = [ [[package]] name = "headers" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" dependencies = [ "base64 0.22.1", "bytes", @@ -5984,7 +5982,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "ureq", "windows-sys 0.60.2", @@ -6009,7 +6007,7 @@ dependencies = [ "once_cell", "rand 0.9.5", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tokio", "tracing", @@ -6033,7 +6031,7 @@ dependencies = [ "resolv-conf", "serde", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] @@ -6267,7 +6265,7 @@ dependencies = [ "hyper-util", "pin-project-lite", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower-service", ] @@ -6333,7 +6331,7 @@ dependencies = [ "rustls 0.23.35", "rustls-native-certs 0.8.4", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tower-service", "webpki-roots 1.0.9", ] @@ -6384,16 +6382,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", "http-body 1.1.0", + "httparse", "hyper 1.11.1", "ipnet", "libc", @@ -6859,7 +6858,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -6908,9 +6907,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -6926,7 +6925,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -6939,7 +6938,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -7083,7 +7082,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tower 0.5.3", @@ -7107,7 +7106,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -7145,7 +7144,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -7442,9 +7441,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] @@ -7571,7 +7570,7 @@ dependencies = [ "rustls-pki-types", "smtp-proto", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "webpki-roots 0.26.11", ] @@ -7920,7 +7919,7 @@ dependencies = [ "quote", "syn 2.0.119", "termcolor", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -7938,7 +7937,7 @@ dependencies = [ "futures-sink", "futures-util", "keyed_priority_queue", - "lru 0.18.4", + "lru 0.18.5", "mysql_common", "native-tls", "pem", @@ -7946,7 +7945,7 @@ dependencies = [ "rand 0.10.3", "serde", "socket2 0.6.5", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-native-tls", "tokio-util", @@ -7978,7 +7977,7 @@ dependencies = [ "serde_json", "sha1", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "uuid", ] @@ -8192,7 +8191,7 @@ dependencies = [ "num-format", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "typetag", "windows-sys 0.48.0", ] @@ -8456,7 +8455,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -8664,7 +8663,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] @@ -8739,7 +8738,7 @@ dependencies = [ "opentelemetry_sdk 0.30.0", "prost", "reqwest 0.12.28", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tonic 0.13.1", "tracing", @@ -8820,7 +8819,7 @@ dependencies = [ "percent-encoding", "rand 0.9.5", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", ] @@ -8924,7 +8923,7 @@ dependencies = [ "rc2", "sha1", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "x509-parser 0.17.0", ] @@ -9261,7 +9260,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.3", + "siphasher 1.0.4", ] [[package]] @@ -9270,7 +9269,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ - "siphasher 1.0.3", + "siphasher 1.0.4", ] [[package]] @@ -9279,7 +9278,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.3", + "siphasher 1.0.4", ] [[package]] @@ -9663,9 +9662,9 @@ dependencies = [ [[package]] name = "process-wrap" -version = "8.2.1" +version = "8.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" +checksum = "e9b56899ac93a78fc49e57d51ca29ceffaa6213ee51af2f8a01b1021ec3b4804" dependencies = [ "futures", "indexmap 2.14.2", @@ -9711,7 +9710,7 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -9863,7 +9862,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls 0.23.35", "socket2 0.6.5", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -9886,7 +9885,7 @@ dependencies = [ "rustls 0.23.35", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -10239,7 +10238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -10357,7 +10356,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tower 0.5.3", "tower-http", @@ -10403,7 +10402,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", "tower 0.5.3", "tower-http", @@ -10426,7 +10425,7 @@ dependencies = [ "http 1.5.0", "reqwest 0.13.5", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tower-service", ] @@ -10445,7 +10444,7 @@ dependencies = [ "reqwest 0.13.5", "reqwest-middleware", "retry-policies", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "wasmtimer", @@ -10558,7 +10557,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -10570,9 +10569,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025" +checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -10929,9 +10928,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -10950,9 +10949,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-tokio-stream" @@ -11115,7 +11114,7 @@ dependencies = [ "quick-xml", "rand 0.9.5", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "uuid", ] @@ -11524,15 +11523,15 @@ dependencies = [ "num-bigint", "serde", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "v8", ] [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -11551,9 +11550,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -11761,7 +11760,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -11773,9 +11772,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "size" @@ -11800,9 +11799,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] @@ -12010,7 +12009,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -12093,7 +12092,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -12133,7 +12132,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -12158,7 +12157,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "url", "uuid", @@ -12870,7 +12869,7 @@ dependencies = [ "itertools 0.14.0", "levenshtein_automata", "log", - "lru 0.18.4", + "lru 0.18.5", "lz4_flex 0.14.0", "measure_time", "memmap2", @@ -12891,7 +12890,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "typetag", "unwrap-infallible", @@ -13087,11 +13086,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -13107,9 +13106,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -13450,9 +13449,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls 0.23.35", "tokio", @@ -13559,7 +13558,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-util", ] @@ -13652,7 +13651,7 @@ dependencies = [ "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -13685,7 +13684,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tower 0.5.3", "tower-layer", @@ -13807,7 +13806,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -14503,9 +14502,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -14517,19 +14516,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14537,9 +14537,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -14550,18 +14550,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.78" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" dependencies = [ "async-trait", "cast", @@ -14581,9 +14581,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.78" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", @@ -14592,9 +14592,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" [[package]] name = "wasm-streams" @@ -14629,7 +14629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" dependencies = [ "deno_error 0.6.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -14648,9 +14648,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -14674,7 +14674,7 @@ checksum = "974fa1e325e6cc5327de8887f189a441fcff4f8eedcd31ec87f0ef0cc5283fbc" dependencies = [ "bytes", "http 1.5.0", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", ] @@ -14792,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -14880,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.817.0" +version = "1.819.0" dependencies = [ "async-stream", "async-trait", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "argon2", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15107,7 +15107,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.817.0" +version = "1.819.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15143,7 +15143,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15160,7 +15160,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15205,7 +15205,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15221,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15265,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -15298,6 +15298,7 @@ dependencies = [ "rumqttc", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "sqlx", "tokio", @@ -15314,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15339,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15367,7 +15368,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15389,7 +15390,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15409,8 +15410,9 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.817.0" +version = "1.819.0" dependencies = [ + "anyhow", "axum 0.8.9", "chrono", "futures", @@ -15435,6 +15437,9 @@ dependencies = [ "windmill-native-triggers", "windmill-object-store", "windmill-parser", + "windmill-parser-bash", + "windmill-parser-go", + "windmill-parser-graphql", "windmill-parser-py", "windmill-parser-py-asset", "windmill-parser-sql", @@ -15447,7 +15452,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15476,7 +15481,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.817.0" +version = "1.819.0" dependencies = [ "lazy_static", "serde", @@ -15488,7 +15493,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.817.0" +version = "1.819.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15512,7 +15517,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15526,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.817.0" +version = "1.819.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15564,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.817.0" +version = "1.819.0" dependencies = [ "chrono", "lazy_static", @@ -15578,7 +15583,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15587,7 +15592,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "uuid", @@ -15597,7 +15602,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.817.0" +version = "1.819.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15682,11 +15687,11 @@ dependencies = [ "systemstat", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tikv-jemalloc-ctl", "tokio", "tokio-postgres", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -15709,7 +15714,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.817.0" +version = "1.819.0" dependencies = [ "chrono", "futures", @@ -15729,7 +15734,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.817.0" +version = "1.819.0" dependencies = [ "regex", "serde", @@ -15746,7 +15751,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15773,7 +15778,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "futures", @@ -15790,7 +15795,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.817.0" +version = "1.819.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15806,7 +15811,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -15827,7 +15832,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -15858,7 +15863,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "arc-swap", @@ -15883,7 +15888,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-stream", @@ -15901,6 +15906,7 @@ dependencies = [ "lazy_static", "object_store", "quick_cache", + "reqwest 0.12.28", "reqwest 0.13.5", "serde", "serde_json", @@ -15918,7 +15924,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "futures", @@ -15928,7 +15934,7 @@ dependencies = [ "serde_json", "serde_yml", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "windmill-common", @@ -15936,7 +15942,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.817.0" +version = "1.819.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15945,7 +15951,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -15957,7 +15963,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -15969,7 +15975,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "gosyn", @@ -15981,7 +15987,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -15993,7 +15999,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -16005,7 +16011,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "nu-parser", @@ -16016,7 +16022,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16027,7 +16033,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16039,7 +16045,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16050,7 +16056,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16072,7 +16078,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -16084,7 +16090,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16098,7 +16104,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16115,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16128,7 +16134,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde", @@ -16140,7 +16146,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16158,7 +16164,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16174,7 +16180,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16190,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -16204,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16231,7 +16237,7 @@ dependencies = [ "serde_urlencoded", "sql-builder", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "ulid", @@ -16243,7 +16249,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "const_format", @@ -16283,7 +16289,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.817.0" +version = "1.819.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16294,7 +16300,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -16329,7 +16335,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16353,7 +16359,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16386,7 +16392,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16398,7 +16404,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-executor-trait", "tokio-reactor-trait", @@ -16413,7 +16419,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16439,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -16446,7 +16452,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16466,7 +16472,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16485,7 +16491,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tonic 0.13.1", @@ -16500,7 +16506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16517,13 +16523,14 @@ dependencies = [ "lazy_static", "matchit 0.7.3", "quick_cache", + "rand 0.9.5", "regex", "serde", "serde_json", "sha1", "sha2 0.10.9", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "windmill-api-auth", @@ -16536,7 +16543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16559,7 +16566,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16578,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "windmill-api-auth", @@ -16583,7 +16590,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-nats", @@ -16607,7 +16614,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16627,7 +16634,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-postgres", "tokio-stream", @@ -16642,7 +16649,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16658,7 +16665,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "windmill-api-auth", @@ -16670,7 +16677,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-trait", @@ -16695,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16714,7 +16721,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-once-cell", @@ -16787,7 +16794,7 @@ dependencies = [ "tiberius", "tokio", "tokio-postgres", - "tokio-rustls 0.26.5", + "tokio-rustls 0.26.6", "tokio-stream", "tokio-util", "tracing", @@ -16832,7 +16839,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.817.0" +version = "1.819.0" dependencies = [ "bytes", "futures", @@ -17454,7 +17461,7 @@ dependencies = [ "nom", "oid-registry 0.8.1", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -17532,18 +17539,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2e81eb2637..77f512a3dd 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.817.0" +version = "1.819.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.817.0" +version = "1.819.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -511,6 +511,8 @@ quick-xml = { version = "^0.37", features = ["serialize"] } url = { version = "^2" , features = ["serde"]} async-oauth2 = "0.5.1" reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] } +# The reqwest object_store builds its HTTP clients with, for a custom `HttpConnector`. +reqwest_object_store = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } eventsource-stream = "0.2.3" time = "^0" serde_urlencoded = "^0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 291341a9ae..263784f2aa 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -bf64ca98472a57cbfd150f8876877d2d4d9c217e +421cf2a8b4f98b421e93c0fc7c1c378314a66e50 diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 72feb5392e..4c78f0524e 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -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: diff --git a/backend/migrations/20260918141240_notify_operator_settings_change.down.sql b/backend/migrations/20260918141240_notify_operator_settings_change.down.sql new file mode 100644 index 0000000000..5f554f6bd0 --- /dev/null +++ b/backend/migrations/20260918141240_notify_operator_settings_change.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS operator_settings_change_trigger ON workspace_settings; +DROP FUNCTION IF EXISTS notify_operator_settings_change(); diff --git a/backend/migrations/20260918141240_notify_operator_settings_change.up.sql b/backend/migrations/20260918141240_notify_operator_settings_change.up.sql new file mode 100644 index 0000000000..164acdb633 --- /dev/null +++ b/backend/migrations/20260918141240_notify_operator_settings_change.up.sql @@ -0,0 +1,18 @@ +-- The write rights in `operator_settings` are authorization decisions read through a per-process +-- cache. Without this, withdrawing one on a single API replica leaves every other replica +-- authorizing writes until its own entry expires. +CREATE OR REPLACE FUNCTION notify_operator_settings_change() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_operator_settings_change', NEW.workspace_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS operator_settings_change_trigger ON workspace_settings; +CREATE TRIGGER operator_settings_change_trigger +AFTER UPDATE OF operator_settings ON workspace_settings +FOR EACH ROW +WHEN (OLD.operator_settings IS DISTINCT FROM NEW.operator_settings) +EXECUTE FUNCTION notify_operator_settings_change(); diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 271ad4835b..ac75080994 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -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 { + 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::>>() + .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::>>() + .map(Value::Object), + _ => None, + } +} + fn eval_span(span: Span, cm: &Lrc) -> Option { let expr = cm .span_to_snippet(span) @@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn eval_sync(_code: &str) -> Result { - 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()) } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4206dd68f8..b4abc23488 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -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#" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index ccba27bcc5..0ad36d8e81 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.817.0" +version = "1.819.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.817.0" +version = "1.819.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.817.0" +version = "1.819.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.817.0" +version = "1.819.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 0244b7b106..597b7621c7 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.817.0" +version = "1.819.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/parsers/windmill-parser/src/json_schema.rs b/backend/parsers/windmill-parser/src/json_schema.rs new file mode 100644 index 0000000000..6ca216dd89 --- /dev/null +++ b/backend/parsers/windmill-parser/src/json_schema.rs @@ -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 { + let mut p = Map::new(); + p.insert("description".into(), json!("")); + p.insert("type".into(), json!("")); + p +} + +fn object_properties( + props: &[ObjectProperty], + old: Option<&Map>, +) -> Map { + 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) -> 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) { + 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 = 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| 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) -> 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"], ""); + } +} diff --git a/backend/parsers/windmill-parser/src/lib.rs b/backend/parsers/windmill-parser/src/lib.rs index 4270edcc79..7ca226b432 100644 --- a/backend/parsers/windmill-parser/src/lib.rs +++ b/backend/parsers/windmill-parser/src/lib.rs @@ -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) diff --git a/backend/src/main.rs b/backend/src/main.rs index 9e7a93cfcd..257ad77d1d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -171,6 +171,7 @@ mod db_connect; pub mod ee; mod ee_oss; mod monitor; +mod stranded_jobs; // Windows service support - EE feature #[cfg(all(windows, feature = "enterprise", feature = "private"))] @@ -1835,6 +1836,13 @@ async fn process_notify_event( ); windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload); } + "notify_operator_settings_change" => { + tracing::info!( + "Operator settings change detected, invalidating operator rights cache: {}", + payload + ); + windmill_common::workspaces::invalidate_operator_rights_cache(payload); + } "notify_asset_producer_change" => { tracing::debug!( "Asset producer change for workspace {}, invalidating producer-writes cache", @@ -1944,13 +1952,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 +2215,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)" ); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 7e4dbb820a..751d19fecc 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -122,9 +122,9 @@ use windmill_common::{ #[cfg(feature = "parquet")] use windmill_object_store::reload_object_store_setting; use windmill_queue::{ - cancel_job, get_queued_job_v2, + add_completed_job_error, cancel_job, canceled_result, get_queued_job_v2, schedule::{find_unarmed_schedules, rearm_schedule, RearmOutcome}, - SameWorkerPayload, + CanceledBy, MiniCompletedJob, SameWorkerPayload, }; use windmill_store::resources::MAX_RESOURCE_VERSIONS; use windmill_worker::{ @@ -4368,6 +4368,15 @@ pub async fn monitor_db( } }; + // run every 30 iterations (~5min at the default LISTEN_NEW_EVENTS_INTERVAL_SEC). + let stranded_jobs_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(30) { + if let Some(db) = conn.as_sql() { + crate::stranded_jobs::check_stranded_jobs(&db).await; + } + } + }; + // Poll git-sync repositories for new commits and pull them into the // workspace (repo → Windmill auto-pull). Runs every 2 iterations. let git_auto_pull_f = async { @@ -4471,6 +4480,7 @@ pub async fn monitor_db( ai_session_retention_f, pipeline_freshness_watchdog_f, reconcile_unarmed_schedules_f, + stranded_jobs_f, ); } @@ -6200,8 +6210,8 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n } /// Force-complete a zombie job that handle_job_error failed to complete. -/// This is a minimal fallback: it inserts a failed completed job and deletes -/// from the queue in a single transaction, without schedule pushing or +/// This is a minimal fallback: it moves the job from the queue to a failed +/// completed job in a single transaction, without schedule pushing or /// error handler logic. The one thing it keeps is the WAC parent notification, /// deliberately inside the transaction: if that fails, the whole completion /// rolls back and the job waits for the next sweep, which is cheaper than a @@ -6238,52 +6248,67 @@ async fn force_complete_zombie_job( let mut tx = db.begin().await?; + // Locks in the order of every other completion: a WAC parent's rows, then this job's queue + // row, then its completed row (see `record_child_completion`). A worker completing the same + // job concurrently would otherwise deadlock against this. let duration_ms = sqlx::query_scalar!( - "INSERT INTO v2_job_completed - (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker) - SELECT q.workspace_id, q.id, q.started_at, - COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint, - $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker - FROM v2_job_queue q - LEFT JOIN v2_job_runtime r ON r.id = q.id - WHERE q.id = $1 - ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb - RETURNING duration_ms AS \"duration_ms!\"", + "SELECT COALESCE(c.duration_ms, COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint) AS \"duration_ms!\" + FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1", job_id, - error_value, ) .fetch_optional(&mut *tx) .await?; + let Some(duration_ms) = duration_ms else { + return Ok(()); + }; // A WAC parent parked on this job must learn of the failure here too, or it // waits out its whole suspend window and runs the task again. let mut wac_parent_ready = false; - if let Some(duration_ms) = duration_ms { - let parent = sqlx::query!( - "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1", - job_id + let parent = sqlx::query!( + "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1", + job_id + ) + .fetch_optional(&mut *tx) + .await?; + if let Some(parent_job) = parent + .filter(|j| j.flow_step_id.is_none()) + .and_then(|j| j.parent_job) + { + wac_parent_ready = windmill_common::wac::record_child_completion( + &mut tx, + &parent_job, + job_id, + false, + duration_ms, + &error_value.to_string(), ) - .fetch_optional(&mut *tx) .await?; - if let Some(parent_job) = parent - .filter(|j| j.flow_step_id.is_none()) - .and_then(|j| j.parent_job) - { - wac_parent_ready = windmill_common::wac::record_child_completion( - &mut tx, - &parent_job, - job_id, - false, - duration_ms, - &error_value.to_string(), - ) - .await?; - } } - sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id) - .execute(&mut *tx) - .await?; + let completed = sqlx::query_scalar!( + "WITH deleted AS ( + DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id, started_at, worker + ) + INSERT INTO v2_job_completed + (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker) + SELECT d.workspace_id, d.id, d.started_at, $3, $2::jsonb, r.memory_peak, + 'failure'::job_status, d.worker + FROM deleted d + LEFT JOIN v2_job_runtime r ON r.id = d.id + ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb + RETURNING id", + job_id, + error_value, + duration_ms, + ) + .fetch_optional(&mut *tx) + .await?; + // Completed by someone else while this waited on the WAC parent: roll back, so the parent + // keeps what the winning completion recorded. + if completed.is_none() { + return Ok(()); + } tx.commit().await?; @@ -6476,6 +6501,9 @@ async fn find_zombie_flow_culprit_worker( } async fn handle_zombie_flows(db: &DB) -> error::Result<()> { + // A canceled flow gets ten times the transition timeout: requeuing it while its worker is + // still in a slow transition lets a second worker complete it under that transition, and + // which worker runs a transition is not recorded anywhere to tell a slow one from a dead one. // flow_status is cast ::text on purpose: decoding the jsonb column directly as Box // yields its binary form (leading version byte) and fails serde_json parsing at column 1. let flows = sqlx::query!( @@ -6491,13 +6519,14 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { wp.worker_group AS "worker_group?", wp.wm_version AS "worker_version?", wp.current_job_id AS "worker_current_job_id?", - wp.worker_instance AS "worker_instance?" + wp.worker_instance AS "worker_instance?", + q.canceled_by AS "canceled_by?", q.canceled_reason AS "canceled_reason?" FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id) LEFT JOIN worker_ping wp ON wp.worker = q.worker WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now() AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow') AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval - AND q.canceled_by IS NULL + AND (q.canceled_by IS NULL OR r.ping < NOW() - ($1 || ' seconds')::interval * 10) "#, FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str() @@ -6510,21 +6539,99 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { .flow_status .as_deref() .and_then(|x| serde_json::from_str::(x).ok()); - if !flow.same_worker.unwrap_or(false) - && status.as_ref().is_some_and(|s| s.is_not_yet_started()) + // A worker that pulls a canceled flow completes it as canceled, and hands that to its + // parent like any step's cancel, so a canceled flow whose transition was lost goes back + // to the queue exactly like a flow that never started. + if flow.canceled_by.is_some() + || (!flow.same_worker.unwrap_or(false) + && status.as_ref().is_some_and(|s| s.is_not_yet_started())) { - let error_message = format!( - "Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.", - flow.id, flow.workspace_id - ); - tracing::error!(error_message); - if !CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART.load(Ordering::Relaxed) { - report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None) - .await; - } - // if the flow hasn't started and is a zombie, we can simply restart it let mut tx = db.begin().await?; + // Claims the flow unless another sweep holds it or it visibly moved on since it was + // read. This does not tell a slow transition from a lost one: the ping is written when + // the step completes, at the start of the transition, and nothing is locked until its + // end, so the grace in the query above is what keeps a live transition safe. + let claimed = sqlx::query_scalar!( + "SELECT q.id FROM v2_job_queue q JOIN v2_job_runtime r ON r.id = q.id + WHERE q.id = $1 AND q.running AND r.ping = $2 + FOR UPDATE OF q SKIP LOCKED", + flow.id, + flow.last_ping, + ) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !claimed { + continue; + } + + let error_message = if let Some(canceler) = flow.canceled_by.as_deref() { + // Bounds the requeues of a canceled flow whose completion keeps failing: past the + // limit it is completed on its own instead, on every sweep until that succeeds. + // The count is taken under the row claim, so the alert goes out once. + let attempt = sqlx::query_scalar!( + "INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, 1) + ON CONFLICT (job_id) DO UPDATE SET counter = zombie_job_counter.counter + 1 + RETURNING counter", + flow.id + ) + .fetch_one(&mut *tx) + .await?; + if attempt > RESTART_LIMIT { + tx.commit().await?; + if attempt == RESTART_LIMIT + 1 { + let error_message = format!( + "Zombie flow detected: {} in workspace {}. It was canceled by {canceler} but could not be completed after {RESTART_LIMIT} attempts, completing it as canceled without handing it to its parent.", + flow.id, flow.workspace_id + ); + tracing::error!(error_message); + report_critical_error( + error_message, + db.clone(), + Some(&flow.workspace_id), + None, + ) + .await; + } + let completed = match get_queued_job_v2(db, &flow.id).await { + Ok(Some(job)) => add_completed_job_error( + db, + &MiniCompletedJob::from(job), + 0, + Some(CanceledBy { + username: Some(canceler.to_string()), + reason: flow.canceled_reason.clone(), + }), + canceled_result(flow.canceled_reason.as_deref(), Some(canceler)), + "monitor", + false, + None, + ) + .await + .map(|_| ()), + Ok(None) => Ok(()), + Err(e) => Err(e), + }; + if let Err(e) = completed { + tracing::error!( + "could not complete canceled zombie flow {}, retrying on the next sweep: {e:#}", + flow.id + ); + } + continue; + } + format!( + "Zombie flow detected: {} in workspace {}. It was canceled by {canceler} but its worker stopped between two steps, queuing it again to complete the cancel ({attempt}/{RESTART_LIMIT}).", + flow.id, flow.workspace_id + ) + } else { + format!( + "Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.", + flow.id, flow.workspace_id + ) + }; + let concurrency_key = sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", flow.id) .fetch_optional(&mut *tx) @@ -6544,15 +6651,26 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { } } + // A canceled flow keeps its start: the pull only sets a missing one, and the canceled + // run's duration is measured from it. sqlx::query!( - "UPDATE v2_job_queue SET running = false, started_at = null - WHERE id = $1 AND canceled_by IS NULL", + "UPDATE v2_job_queue SET running = false, + started_at = CASE WHEN canceled_by IS NULL THEN NULL ELSE started_at END + WHERE id = $1", flow.id ) .execute(&mut *tx) .await?; tx.commit().await?; + + tracing::error!(error_message); + if flow.canceled_by.is_some() + || !CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART.load(Ordering::Relaxed) + { + report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None) + .await; + } } else { let id = flow.id.clone(); let last_ping = flow.last_ping.clone(); @@ -7772,3 +7890,154 @@ mod log_file_listing_tests { assert_eq!(files[0].0.to_string(), "2026-08-29 06:46:00"); } } + +#[cfg(test)] +mod canceled_zombie_flow_tests { + use super::{handle_zombie_flows, DB, RESTART_LIMIT}; + use serde_json::json; + use uuid::Uuid; + + /// A running flow at `step` (0: not started yet), last pinged `ping_age` ago (`None`: mid-step, no ping). + async fn insert_flow( + db: &DB, + parent: Option, + step: i32, + ping_age: Option<&str>, + canceled_by: Option<&str>, + ) -> anyhow::Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, tag, parent_job, flow_step_id) + VALUES ($1, 'admins', 'admin', 'u/admin', 'admin@windmill.dev', 'flowpreview', 'flow', + $2, CASE WHEN $2 IS NOT NULL THEN 'sf' END)", + ) + .bind(id) + .bind(parent) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag, + canceled_by, canceled_reason) + VALUES ($1, 'admins', now(), true, now() - interval '1 hour', 'flow', $2, $2)", + ) + .bind(id) + .bind(canceled_by) + .execute(db) + .await?; + sqlx::query("INSERT INTO v2_job_runtime (id, ping) VALUES ($1, now() - $2::interval)") + .bind(id) + .bind(ping_age) + .execute(db) + .await?; + sqlx::query("INSERT INTO v2_job_status (id, flow_status) VALUES ($1, $2)") + .bind(id) + .bind(json!({"step": step, "modules": [if step == 0 { + json!({"type": "WaitingForPriorSteps", "id": "a"}) + } else { + json!({"type": "InProgress", "id": "a", "job": Uuid::nil()}) + }], + "failure_module": {"type": "WaitingForPriorSteps", "id": "failure"}})) + .execute(db) + .await?; + Ok(id) + } + + /// `(running, canceled_by, still started an hour ago)` + async fn queue_row(db: &DB, id: Uuid) -> anyhow::Result<(bool, Option, Option)> { + Ok(sqlx::query_as( + "SELECT running, canceled_by, started_at < now() - interval '30 minutes' + FROM v2_job_queue WHERE id = $1", + ) + .bind(id) + .fetch_one(db) + .await?) + } + + /// A subflow canceled on its own whose worker died between two steps goes back to the queue + /// with its cancel and its start, for a worker to complete it and hand it to its parent. The + /// parent is left alone: the cancel must not reach flows the user never canceled. + #[sqlx::test(migrations = "./migrations")] + async fn requeues_a_stranded_canceled_flow_and_nothing_else(db: DB) -> anyhow::Result<()> { + let root = insert_flow(&db, None, 0, None, None).await?; + let child = insert_flow(&db, Some(root), 1, Some("1 hour"), Some("admin")).await?; + + handle_zombie_flows(&db).await?; + + assert_eq!( + queue_row(&db, child).await?, + (false, Some("admin".to_string()), Some(true)) + ); + assert_eq!(queue_row(&db, root).await?, (true, None, Some(true))); + Ok(()) + } + + /// A canceled flow is left to a transition that may just be slow for ten times the timeout + /// a flow that never started gets. + #[sqlx::test(migrations = "./migrations")] + async fn gives_a_canceled_flow_a_longer_grace(db: DB) -> anyhow::Result<()> { + let slow = insert_flow(&db, None, 1, Some("2 minutes"), Some("admin")).await?; + let stuck = insert_flow(&db, None, 1, Some("1 hour"), Some("admin")).await?; + let not_started = insert_flow(&db, None, 0, Some("2 minutes"), None).await?; + + handle_zombie_flows(&db).await?; + + assert!( + queue_row(&db, slow).await?.0, + "a recent canceled flow is left running" + ); + assert!( + !queue_row(&db, stuck).await?.0, + "past the grace, it is requeued" + ); + assert!( + !queue_row(&db, not_started).await?.0, + "a flow that never started is not held back" + ); + Ok(()) + } + + /// A canceled flow that keeps coming back after its requeues is completed as canceled on its + /// own instead of being requeued (and alerted on) forever, and that completion is retried by + /// the next sweep if a previous one did not land. Its parent is not advanced, since the + /// transition that would do it is what kept failing. + #[sqlx::test(migrations = "./migrations")] + async fn completes_a_canceled_flow_requeued_too_often(db: DB) -> anyhow::Result<()> { + let root = insert_flow(&db, None, 0, None, None).await?; + let child = insert_flow(&db, Some(root), 1, Some("1 hour"), Some("admin")).await?; + let retried = insert_flow(&db, None, 1, Some("1 hour"), Some("admin")).await?; + for (id, counter) in [(child, RESTART_LIMIT), (retried, RESTART_LIMIT + 1)] { + sqlx::query("INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, $2)") + .bind(id) + .bind(counter) + .execute(&db) + .await?; + } + + handle_zombie_flows(&db).await?; + + for id in [child, retried] { + let completed: Option<(String, Option)> = sqlx::query_as( + "SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1", + ) + .bind(id) + .fetch_optional(&db) + .await?; + assert_eq!( + completed, + Some(("canceled".to_string(), Some("admin".to_string()))) + ); + } + // the parent is not advanced, but the completion pings it: the sweep then reaps it as a + // flow hanging between two steps instead of leaving it waiting + assert_eq!(queue_row(&db, root).await?, (true, None, Some(true))); + let parent_pinged: bool = sqlx::query_scalar( + "SELECT ping > now() - interval '1 minute' FROM v2_job_runtime WHERE id = $1", + ) + .bind(root) + .fetch_one(&db) + .await?; + assert!(parent_pinged); + Ok(()) + } +} diff --git a/backend/src/stranded_jobs.rs b/backend/src/stranded_jobs.rs new file mode 100644 index 0000000000..6b51eeba40 --- /dev/null +++ b/backend/src/stranded_jobs.rs @@ -0,0 +1,664 @@ +//! Pending jobs stuck on a tag no worker serves. +//! +//! Such a job waits forever, and every idle pull of every worker group walks past it in the +//! shared `queue_sort_v2` index. This pass alerts superadmins about them and, when +//! `cancel_stranded_jobs_after_days` is set, cancels them. + +use chrono::{DateTime, Utc}; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::{ + error, + global_settings::{ + load_value_from_global_settings, CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING, + CRITICAL_ALERT_MUTE_STRANDED_JOBS_SETTING, + }, + utils::report_critical_error, +}; +use windmill_queue::cancel_job; + +/// How long a tag must go without a worker serving it before its pending jobs are reported. +/// Long enough that a group autoscaled to zero, or only started at night, is not reported. +const ALERT_WINDOW_SECS: i64 = 24 * 3600; + +/// At most one alert per this interval, however many tags are stranded. +const ALERT_COOLDOWN_SECS: i64 = 24 * 3600; + +/// Tags listed in one alert; the rest are counted. +const MAX_LISTED_GROUPS: usize = 20; + +/// Upper bound on cancellations per pass; a larger backlog drains over the next passes. +const MAX_CANCELS_PER_PASS: i64 = 500; + +const MAX_CANCEL_AFTER_DAYS: i64 = 3650; + +/// Next to the other monitor pass locks (737_483_920..=737_483_923). +const STRANDED_JOBS_LOCK_ID: i64 = 737_483_924; + +const ALERT_RESOURCE: &str = "stranded_jobs"; + +/// Every cancel reason starts with it, which is how `finish_lost_cancels` recognises them. +const CANCEL_REASON_PREFIX: &str = "no worker has served tag '"; + +pub async fn check_stranded_jobs(db: &Pool) { + // Transaction-scoped so a monitor timeout dropping this future cannot leave the lock held + // on a pooled connection (see `reconcile_unarmed_schedules`). + let mut lock_tx = match db.begin().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!("stranded jobs: failed to begin lock tx: {e:#}"); + return; + } + }; + match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_xact_lock($1)") + .bind(STRANDED_JOBS_LOCK_ID) + .fetch_one(&mut *lock_tx) + .await + { + Ok(true) => {} + Ok(false) => return, + Err(e) => { + tracing::error!("stranded jobs: advisory lock failed: {e:#}"); + return; + } + } + + let cancel_after_days = match cancel_after_days(db).await { + Ok(d) => d, + Err(e) => { + tracing::error!( + "stranded jobs: could not read {CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING}: {e:#}" + ); + None + } + }; + + if let Err(e) = run_pass(db, cancel_after_days).await { + tracing::error!("stranded jobs: {e:#}"); + } + + let _ = lock_tx.rollback().await; +} + +async fn cancel_after_days(db: &Pool) -> error::Result> { + Ok( + load_value_from_global_settings(db, CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING) + .await? + .and_then(|v| v.as_i64()) + .filter(|d| *d > 0) + .map(|d| d.min(MAX_CANCEL_AFTER_DAYS)), + ) +} + +async fn run_pass(db: &Pool, cancel_after_days: Option) -> error::Result<()> { + finish_lost_cancels(db).await?; + + // With no worker pinging at all over a window, every tag looks unserved: that is a fleet + // outage, which would otherwise raise one alert per tag or cancel the whole queue. Each + // window gets its own check: a fleet quiet for a day may still show which tags went + // unserved over a longer cancel window. + let groups = if any_worker_pinged(db, ALERT_WINDOW_SECS).await? { + find_stranded(db, ALERT_WINDOW_SECS).await? + } else { + tracing::warn!("stranded jobs: no worker pinged in the last 24 hours, not alerting"); + vec![] + }; + report_stranded_jobs(db, &groups, cancel_after_days).await?; + + if let Some(days) = cancel_after_days { + let window_secs = days * 24 * 3600; + let groups = if window_secs == ALERT_WINDOW_SECS { + groups + } else if any_worker_pinged(db, window_secs).await? { + find_stranded(db, window_secs).await? + } else { + tracing::warn!( + "stranded jobs: no worker pinged in the last {days} days, not cancelling" + ); + vec![] + }; + cancel_stranded_jobs(db, &groups, days).await?; + } + Ok(()) +} + +async fn any_worker_pinged(db: &Pool, window_secs: i64) -> error::Result { + Ok(sqlx::query_scalar!( + r#"SELECT EXISTS (SELECT 1 FROM worker_ping + WHERE ping_at > now() - $1::bigint * interval '1 second') AS "exists!""#, + window_secs, + ) + .fetch_one(db) + .await?) +} + +/// Stranded jobs of one tag in one workspace. +struct StrandedGroup { + workspace_id: String, + tag: String, + count: i64, + oldest: DateTime, +} + +/// Pending jobs due for at least `window_secs` whose tag no worker that pinged within the same +/// window serves, grouped by workspace and tag. A job whose parent is still queued is covered +/// by that parent; one whose parent is gone (a native script retry points at the completed +/// original) stands on its own. +/// +/// A worker pulls its `custom_tags`, which hold resolved tags (dedicated-worker tag included), +/// the positive-priority `priority_tags` of its group config (which may be absent from +/// `custom_tags`), and its own name prefix. Jobs carry their resolved tag (per-workspace suffix, `$workspace`) +/// and the pull matches it exactly, so exact comparison is the pull's own criterion. Agent +/// workers ping `worker_ping` through the API, so they count too. `worker_ping` keeps a worker's +/// current tags only: a tag removed from a group's config stops counting at the next ping. +async fn find_stranded(db: &Pool, window_secs: i64) -> error::Result> { + // MATERIALIZED keeps the queue driving the plan: joined freely, the planner may instead walk + // every root job of `v2_job`, which holds the whole job history. + Ok(sqlx::query_as!( + StrandedGroup, + r#"WITH served AS ( + SELECT unnest(custom_tags) AS tag FROM worker_ping + WHERE ping_at > now() - $1::bigint * interval '1 second' + UNION SELECT regexp_replace(worker, '-[^-]*$', '') FROM worker_ping + WHERE ping_at > now() - $1::bigint * interval '1 second' + UNION SELECT p.key + FROM worker_ping w JOIN config c ON c.name = 'worker__' || w.worker_group, + jsonb_each(c.config->'priority_tags') p + WHERE w.ping_at > now() - $1::bigint * interval '1 second' + AND jsonb_typeof(c.config->'priority_tags') = 'object' + AND jsonb_typeof(p.value) = 'number' AND p.value::text::numeric > 0 + ), + pending AS MATERIALIZED ( + SELECT q.id, q.workspace_id, q.tag, q.scheduled_for FROM v2_job_queue q + WHERE q.running = false + AND q.canceled_by IS NULL + AND q.scheduled_for <= now() - $1::bigint * interval '1 second' + AND NOT EXISTS (SELECT 1 FROM served s WHERE s.tag = q.tag) + ) + SELECT p.workspace_id AS "workspace_id!", p.tag AS "tag!", count(*) AS "count!", + min(p.scheduled_for) AS "oldest!" + FROM pending p JOIN v2_job j ON j.id = p.id + WHERE (j.parent_job IS NULL + OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job)) + GROUP BY p.workspace_id, p.tag + ORDER BY min(p.scheduled_for)"#, + window_secs, + ) + .fetch_all(db) + .await?) +} + +async fn report_stranded_jobs( + db: &Pool, + groups: &[StrandedGroup], + cancel_after_days: Option, +) -> error::Result<()> { + if groups.is_empty() { + return Ok(()); + } + let muted = load_value_from_global_settings(db, CRITICAL_ALERT_MUTE_STRANDED_JOBS_SETTING) + .await? + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if muted { + return Ok(()); + } + // Read from `alerts` rather than kept in memory so the limit holds across servers. + let alerted_recently = sqlx::query_scalar!( + r#"SELECT EXISTS (SELECT 1 FROM alerts + WHERE alert_type = 'critical_error' AND resource = $1 + AND created_at > now() - $2::bigint * interval '1 second') AS "exists!""#, + ALERT_RESOURCE, + ALERT_COOLDOWN_SECS, + ) + .fetch_one(db) + .await?; + if alerted_recently { + return Ok(()); + } + + let now = Utc::now(); + let total: i64 = groups.iter().map(|g| g.count).sum(); + let mut message = format!( + "{} waiting on tags that no worker has served in the last {}:", + plural(total, "pending job"), + fmt_duration(ALERT_WINDOW_SECS), + ); + for g in groups.iter().take(MAX_LISTED_GROUPS) { + message.push_str(&format!( + "\n- workspace {}, tag '{}': {}, oldest waiting {}", + g.workspace_id, + g.tag, + plural(g.count, "job"), + fmt_duration((now - g.oldest).num_seconds()), + )); + } + if groups.len() > MAX_LISTED_GROUPS { + message.push_str(&format!( + "\n- and {} more", + plural((groups.len() - MAX_LISTED_GROUPS) as i64, "tag") + )); + } + match cancel_after_days { + Some(days) => message.push_str(&format!( + "\nJobs whose tag no worker serves for {} are canceled automatically.", + plural(days, "day") + )), + None => message.push_str( + "\nAdd these tags to a worker group or cancel the jobs. To cancel such jobs automatically, set 'Cancel jobs on unserved tags after (days)' in Instance settings > Jobs.", + ), + } + message.push_str( + "\nThis alert is sent at most once a day. To turn it off, enable 'Mute stranded job alerts' in Instance settings > Alerts.", + ); + + tracing::warn!("stranded jobs: {message}"); + report_critical_error(message, db.clone(), None, Some(ALERT_RESOURCE)).await; + Ok(()) +} + +async fn cancel_stranded_jobs( + db: &Pool, + groups: &[StrandedGroup], + days: i64, +) -> error::Result<()> { + let window_secs = days * 24 * 3600; + let mut budget = MAX_CANCELS_PER_PASS; + for group in groups { + if budget <= 0 { + break; + } + let ids = sqlx::query_scalar!( + r#"WITH pending AS MATERIALIZED ( + SELECT q.id, q.scheduled_for FROM v2_job_queue q + WHERE q.workspace_id = $1 AND q.tag = $2 + AND q.running = false + AND q.canceled_by IS NULL + AND q.scheduled_for <= now() - $3::bigint * interval '1 second' + ) + SELECT p.id FROM pending p JOIN v2_job j ON j.id = p.id + WHERE (j.parent_job IS NULL + OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job)) + ORDER BY p.scheduled_for + LIMIT $4"#, + group.workspace_id, + group.tag, + window_secs, + budget, + ) + .fetch_all(db) + .await?; + budget -= ids.len() as i64; + tracing::warn!( + workspace_id = group.workspace_id, + tag = group.tag, + "stranded jobs: cancelling {} of {} pending job(s) on a tag no worker served in the last {days} day(s)", + ids.len(), + group.count, + ); + let reason = format!( + "{CANCEL_REASON_PREFIX}{}' for {}", + group.tag, + plural(days, "day") + ); + for id in ids { + if let Err(e) = + cancel_one(db, id, &group.workspace_id, reason.clone(), window_secs).await + { + tracing::error!("stranded jobs: could not cancel job {id}: {e:#}"); + } + } + } + Ok(()) +} + +async fn cancel_one( + db: &Pool, + id: Uuid, + workspace_id: &str, + reason: String, + window_secs: i64, +) -> error::Result<()> { + let mut tx = db.begin().await?; + // A worker for the tag may have come back since the job was selected. The row lock keeps the + // pull (which skips locked rows) off it, and `canceled_by` is set under that lock because + // `cancel_job` completes a pending job asynchronously: a worker pulling it in between + // completes it as canceled instead of running it. That completion is lost if the server + // stops first; `finish_lost_cancels` picks those rows up. + let still_stranded = sqlx::query_scalar!( + r#"UPDATE v2_job_queue q SET canceled_by = 'monitor', canceled_reason = $3 + WHERE q.id = $1 AND q.running = false AND q.canceled_by IS NULL + AND NOT EXISTS (SELECT 1 FROM worker_ping w + LEFT JOIN config c ON c.name = 'worker__' || w.worker_group + WHERE w.ping_at > now() - $2::bigint * interval '1 second' + AND (q.tag = ANY(w.custom_tags) + OR q.tag = regexp_replace(w.worker, '-[^-]*$', '') + OR (jsonb_typeof(c.config->'priority_tags'->q.tag) = 'number' + AND (c.config->'priority_tags'->>q.tag)::numeric > 0))) + RETURNING q.id"#, + id, + window_secs, + reason, + ) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !still_stranded { + return Ok(()); + } + complete_canceled(tx, db, id, workspace_id, reason).await +} + +/// Forced: the non-forced path leaves a job that has a parent to its worker, and no worker +/// serves this one. A job whose parent is still queued never gets here. +async fn complete_canceled( + tx: sqlx::Transaction<'_, Postgres>, + db: &Pool, + id: Uuid, + workspace_id: &str, + reason: String, +) -> error::Result<()> { + let (tx, _) = cancel_job( + "monitor", + Some(reason), + id, + workspace_id, + tx, + db, + true, + false, + ) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Completes jobs an earlier pass marked canceled whose asynchronous completion was lost (the +/// server stopped first). Runs whatever the cancel setting now says: the decision was made. +async fn finish_lost_cancels(db: &Pool) -> error::Result<()> { + let rows = sqlx::query!( + r#"SELECT id, workspace_id, canceled_reason AS "reason!" FROM v2_job_queue + WHERE running = false AND canceled_by = 'monitor' AND canceled_reason LIKE $1 || '%' + LIMIT $2"#, + CANCEL_REASON_PREFIX, + MAX_CANCELS_PER_PASS, + ) + .fetch_all(db) + .await?; + for row in rows { + let mut tx = db.begin().await?; + let still_marked = sqlx::query_scalar!( + "SELECT id FROM v2_job_queue WHERE id = $1 AND running = false FOR UPDATE", + row.id + ) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if still_marked { + tracing::warn!("stranded jobs: completing canceled job {}", row.id); + if let Err(e) = complete_canceled(tx, db, row.id, &row.workspace_id, row.reason).await { + tracing::error!("stranded jobs: could not complete job {}: {e:#}", row.id); + } + } + } + Ok(()) +} + +fn fmt_duration(secs: i64) -> String { + let hours = secs / 3600; + if hours >= 48 { + plural(hours / 24, "day") + } else if hours >= 1 { + plural(hours, "hour") + } else { + plural(secs / 60, "minute") + } +} + +fn plural(n: i64, unit: &str) -> String { + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn plant(db: &Pool, tag: &str, age: &str, parent: Option) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, tag, parent_job) VALUES ($1, 'admins', $2, $3)", + ) + .bind(id) + .bind(tag) + .bind(parent) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, tag, scheduled_for) + VALUES ($1, 'admins', $2, now() - $3::interval)", + ) + .bind(id) + .bind(tag) + .bind(age) + .execute(db) + .await + .unwrap(); + id + } + + async fn ping(db: &Pool, worker: &str, group: &str, age: &str, tags: &[&str]) { + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, worker_group, ping_at, custom_tags) + VALUES ($1, 'test', $2, now() - $3::interval, $4)", + ) + .bind(worker) + .bind(group) + .bind(age) + .bind(tags) + .execute(db) + .await + .unwrap(); + } + + async fn set_canceled_by(db: &Pool, id: Uuid, by: Option<&str>) { + sqlx::query("UPDATE v2_job_queue SET canceled_by = $2 WHERE id = $1") + .bind(id) + .bind(by) + .execute(db) + .await + .unwrap(); + } + + async fn canceled_by(db: &Pool, id: Uuid) -> Option { + sqlx::query_scalar("SELECT canceled_by FROM v2_job_queue WHERE id = $1") + .bind(id) + .fetch_one(db) + .await + .unwrap() + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_stranded_selects_unserved_root_jobs(db: Pool) { + ping(&db, "wk-live-a1b2", "live", "1 minute", &["live"]).await; + // A group scaled to zero within the window still serves its tags. + ping(&db, "wk-night-c3d4", "night", "10 hours", &["night"]).await; + ping(&db, "wk-gone-e5f6", "gone", "2 days", &["gone"]).await; + // A positive-priority tag is pulled even when absent from the worker's tags. + ping(&db, "wk-prio-g7h8", "prio", "1 minute", &["other"]).await; + sqlx::query( + r#"INSERT INTO config (name, config) VALUES ('worker__prio', '{"priority_tags": {"prio": 2, "zero": 0}}')"#, + ) + .execute(&db) + .await + .unwrap(); + + let nobody = plant(&db, "nobody", "3 days", None).await; + plant(&db, "gone", "3 days", None).await; + let live = plant(&db, "live", "3 days", None).await; + plant(&db, "night", "3 days", None).await; + plant(&db, "prio", "3 days", None).await; + // Priority 0 is only pulled when also in the worker's tags. + plant(&db, "zero", "3 days", None).await; + // A worker also pulls its own name prefix. + plant(&db, "wk-live", "3 days", None).await; + plant(&db, "nobody", "1 hour", None).await; + plant(&db, "nobody", "-1 day", None).await; + plant(&db, "nobody", "3 days", Some(live)).await; + // A native retry points at the completed original, which has left the queue. + let original = Uuid::new_v4(); + sqlx::query("INSERT INTO v2_job (id, workspace_id, tag) VALUES ($1, 'admins', 'retry')") + .bind(original) + .execute(&db) + .await + .unwrap(); + plant(&db, "retry", "3 days", Some(original)).await; + let marked = plant(&db, "nobody", "3 days", None).await; + set_canceled_by(&db, marked, Some("monitor")).await; + let by_user = plant(&db, "nobody", "3 days", None).await; + set_canceled_by(&db, by_user, Some("alice")).await; + let running = plant(&db, "nobody", "3 days", None).await; + sqlx::query("UPDATE v2_job_queue SET running = true WHERE id = $1") + .bind(running) + .execute(&db) + .await + .unwrap(); + + let mut found: Vec<(String, i64)> = find_stranded(&db, ALERT_WINDOW_SECS) + .await + .unwrap() + .into_iter() + .map(|g| (g.tag, g.count)) + .collect(); + found.sort(); + assert_eq!( + found, + vec![ + ("gone".to_string(), 1), + ("nobody".to_string(), 1), + ("retry".to_string(), 1), + ("zero".to_string(), 1) + ] + ); + + // Picked up by a worker after selection: must not be interrupted. + cancel_one( + &db, + running, + "admins", + "test".to_string(), + ALERT_WINDOW_SECS, + ) + .await + .unwrap(); + assert_eq!(canceled_by(&db, running).await, None); + + // Marked before its asynchronous completion, so a worker pulling it meanwhile skips it. + cancel_one(&db, nobody, "admins", "test".to_string(), ALERT_WINDOW_SECS) + .await + .unwrap(); + assert_eq!(cancel_state(&db, nobody).await, Some("monitor".to_string())); + } + + /// `canceled_by` of a job, whether still queued or already completed. + async fn cancel_state(db: &Pool, id: Uuid) -> Option { + // Queue first: the completion moves the row to `v2_job_completed` in one transaction. + let queued: Option> = + sqlx::query_scalar("SELECT canceled_by FROM v2_job_queue WHERE id = $1") + .bind(id) + .fetch_optional(db) + .await + .unwrap(); + match queued { + Some(by) => by, + None => sqlx::query_scalar("SELECT canceled_by FROM v2_job_completed WHERE id = $1") + .bind(id) + .fetch_optional(db) + .await + .unwrap() + .flatten(), + } + } + + #[sqlx::test(migrations = "./migrations")] + async fn lost_cancels_are_completed(db: Pool) { + let job = plant(&db, "nobody", "3 days", None).await; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'monitor', canceled_reason = $2 WHERE id = $1", + ) + .bind(job) + .bind(format!("{CANCEL_REASON_PREFIX}nobody' for 1 day")) + .execute(&db) + .await + .unwrap(); + + finish_lost_cancels(&db).await.unwrap(); + + for _ in 0..50 { + let done: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM v2_job_completed WHERE id = $1 AND status = 'canceled')", + ) + .bind(job) + .fetch_one(&db) + .await + .unwrap(); + if done { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("job was not completed as canceled"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn fleet_outage_neither_alerts_nor_cancels(db: Pool) { + ping(&db, "wk-all-a1b2", "all", "2 days", &["gone"]).await; + let job = plant(&db, "nobody", "3 days", None).await; + + run_pass(&db, Some(1)).await.unwrap(); + + let alerts: i64 = sqlx::query_scalar("SELECT count(*) FROM alerts") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(alerts, 0); + assert_eq!(canceled_by(&db, job).await, None); + } + + #[sqlx::test(migrations = "./migrations")] + async fn alerts_at_most_daily_and_can_be_muted(db: Pool) { + let groups = vec![StrandedGroup { + workspace_id: "admins".to_string(), + tag: "nobody".to_string(), + count: 3, + oldest: Utc::now() - chrono::Duration::days(3), + }]; + let alerts = || async { + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM alerts") + .fetch_one(&db) + .await + .unwrap() + }; + + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ('critical_alert_mute_stranded_jobs', 'true')", + ) + .execute(&db) + .await + .unwrap(); + report_stranded_jobs(&db, &groups, None).await.unwrap(); + assert_eq!(alerts().await, 0); + + sqlx::query("DELETE FROM global_settings WHERE name = 'critical_alert_mute_stranded_jobs'") + .execute(&db) + .await + .unwrap(); + report_stranded_jobs(&db, &groups, None).await.unwrap(); + report_stranded_jobs(&db, &groups, None).await.unwrap(); + assert_eq!(alerts().await, 1); + } +} diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 7af2e0794e..105458847a 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> 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) -> 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) -> 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(()) } diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs index e2bbf631db..d13d34a6c7 100644 --- a/backend/tests/app_run_mode_lock_strip.rs +++ b/backend/tests/app_run_mode_lock_strip.rs @@ -238,3 +238,65 @@ async fn test_run_mode_rejects_traversal_component(db: Pool) -> anyhow Ok(()) } + +/// A deployed app's inline script (`app_script` id) runs as an `appscript` job whose +/// path job identity reads (OIDC `sub`), so it is derived server-side there too. +#[sqlx::test(fixtures("base"))] +async fn test_app_script_run_derives_path(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + let app_path = "u/test-user/appscript_path"; + let pin = rawscript_pin(CONTENT); + let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN") + .json(&json!({ + "path": app_path, + "summary": "", + "value": {}, + "policy": { + "execution_mode": "viewer", + "triggerables_v2": { format!("comp:{pin}"): { "static_inputs": {}, "one_of_inputs": {} } } + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?); + + let sha = pin.trim_start_matches("rawscript/").to_string(); + let script_id: i64 = sqlx::query_scalar( + "INSERT INTO app_script (app, hash, code, code_sha256) + SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id", + ) + .bind(app_path) + .bind(&sha) + .bind(CONTENT) + .fetch_one(&db) + .await?; + + let resp = authed( + client().post(format!("{ws}/apps_u/execute_component/{app_path}")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "args": {}, + "component": "comp", + "id": script_id, + "raw_code": { "language": "python3", "content": "", "path": CALLER_PATH } + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "deployed app script run must be accepted: {body}"); + let uuid = uuid::Uuid::parse_str(body.trim())?; + let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?; + assert_eq!( + runnable_path.as_deref(), + Some(format!("{app_path}/comp").as_str()), + "an app_script run must not take the caller's path" + ); + + Ok(()) +} diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index b1708465f9..51a6afa565 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -615,3 +615,46 @@ async fn list_groups_ssf_flow_has_schema_via_path(db: Pool) -> anyhow: ); Ok(()) } + +/// A member who may run the script but cannot see another user's run of it must not +/// be able to rerun that run: the rerun would hand them a job carrying its args. +#[sqlx::test(fixtures("base", "batch_rerun"))] +async fn batch_rerun_denies_invisible_source_job(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let member = windmill_api_client::create_client( + &format!("http://localhost:{}", server.addr.port()), + "SECRET_TOKEN_2".to_string(), + ); + // `false` is a read (run) grant, not a denial: test-user-2 can run the script. + sqlx::query("UPDATE script SET extra_perms = '{\"u/test-user-2\": false}' WHERE hash = $1") + .bind(SCRIPT_HASH) + .execute(&db) + .await?; + + let original = push_completed(&db, script_payload(), vec![("name", json!("secret"))]).await?; + + let results = batch_rerun( + &member, + json!({ + "job_ids": [original], + "script_options_by_path": {}, + "flow_options_by_path": {}, + }), + ) + .await?; + assert_eq!(results.len(), 1, "the denied job must be reported"); + assert!( + results[0] + .as_ref() + .is_err_and(|e| e.starts_with("Permission denied")), + "rerun must be denied by the read gate: {:?}", + results[0] + ); + let pushed: i64 = + sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE created_by = 'test-user-2'") + .fetch_one(&db) + .await?; + assert_eq!(pushed, 0); + Ok(()) +} diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index f4b74525fb..59895b7a3a 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -539,4 +539,57 @@ def main(): .await; Ok(()) } + + #[sqlx::test(fixtures("base"))] + async fn relock_of_importer_runs_on_its_tag(db: Pool) -> anyhow::Result<()> { + for (path, hash, tag) in [ + ("f/tags/tagged", 7_000_001_i64, Some("mytag")), + ("f/tags/plain", 7_000_002_i64, None), + ] { + sqlx::query( + "INSERT INTO script(workspace_id, created_by, content, schema, summary, description, path, hash, language, tag) + VALUES ('test-workspace', 'test-user', 'def main(): pass', '{}', '', '', $1, $2, 'python3', $3)", + ) + .bind(path) + .bind(hash) + .bind(tag) + .execute(&db) + .await?; + } + let importers = ["f/tags/tagged", "f/tags/plain"] + .map( + |p| windmill_dep_map::scoped_dependency_map::DependencyDependent { + importer_path: p.to_string(), + importer_kind: "script".to_string(), + importer_node_ids: None, + }, + ) + .to_vec(); + windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies( + "test-workspace", + importers, + None, + None, + "test@windmill.dev", + "test-user", + "u/test-user", + &db, + vec![], + ) + .await?; + + let tags: Vec<(String, String)> = sqlx::query_as( + "SELECT runnable_path, tag FROM v2_job WHERE kind = 'dependencies' ORDER BY runnable_path", + ) + .fetch_all(&db) + .await?; + assert_eq!( + tags, + vec![ + ("f/tags/plain".to_string(), "python3".to_string()), + ("f/tags/tagged".to_string(), "mytag".to_string()), + ] + ); + Ok(()) + } } diff --git a/backend/tests/fixtures/job_provenance.sql b/backend/tests/fixtures/job_provenance.sql new file mode 100644 index 0000000000..202c8f2df7 --- /dev/null +++ b/backend/tests/fixtures/job_provenance.sql @@ -0,0 +1,32 @@ +-- Add-on fixture for job_provenance.rs (combine with `base.sql`). A deployed script +-- f/t/tool run as a step under three parents: the deployed flow f/t/agent, a flow +-- preview claiming that path, and f/t/agent running the version of another flow. + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) +VALUES ('test-workspace', 1111111111, 'f/t/tool', 'echo tool', 'bash', 'script', 'test-user', '{}', '', '', ''); + +INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions) +VALUES + ('test-workspace', 'f/t/agent', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[2222222222::bigint]), + ('test-workspace', 'u/test-user/evil', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[3333333333::bigint]); + +INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at) +VALUES + (2222222222, 'test-workspace', 'f/t/agent', '{"modules":[]}', '{}', 'test-user', NOW()), + (3333333333, 'test-workspace', 'u/test-user/evil', '{"modules":[]}', '{}', 'test-user', NOW()); + +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000001', 'test-workspace', 'flow', 'f/t/agent', 2222222222, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000002', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000003', 'test-workspace', 'flowpreview', 'f/t/agent', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000004', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000005', 'test-workspace', 'flow', 'f/t/agent', 3333333333, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000006', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000005', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- An app script run of f/t/app stamped by a deployed-app run, and one that is not +-- (an app editor preview). +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000007', 'test-workspace', 'appscript', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000008', 'test-workspace', 'appscript', 'f/t/app/comp', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'); diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index b6a943a23b..7ea221c2e8 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> 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, +) -> 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)>( + "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. diff --git a/backend/tests/flow_step_completion_ping.rs b/backend/tests/flow_step_completion_ping.rs new file mode 100644 index 0000000000..89dab1ceef --- /dev/null +++ b/backend/tests/flow_step_completion_ping.rs @@ -0,0 +1,108 @@ +//! A flow step's completion is the progress that keeps its flow from being reaped as a zombie: it +//! must refresh the parent's ping, canceled or not, since a canceled flow still needs its next +//! transition to complete. + +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"; + +async fn insert_running( + db: &Pool, + kind: &str, + parent: Option, + step_id: Option<&str>, +) -> anyhow::Result { + 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_path, script_lang, tag, visible_to_owner, \ + parent_job, flow_step_id) \ + VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', \ + $3::job_kind, 'u/test-user/f', 'bash', 'bash', true, $4, $5)", + ) + .bind(id) + .bind(W_ID) + .bind(kind) + .bind(parent) + .bind(step_id) + .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?; + sqlx::query("INSERT INTO v2_job_runtime (id, ping) VALUES ($1, now() - interval '1 hour')") + .bind(id) + .execute(db) + .await?; + Ok(id) +} + +async fn complete_step_and_read_parent_ping_age( + db: &Pool, + canceled: bool, +) -> anyhow::Result { + let parent = insert_running(db, "flow", None, None).await?; + let step = insert_running(db, "script", Some(parent), Some("a")).await?; + if canceled { + // a cancel of the flow marks the flow and each of its steps + sqlx::query("UPDATE v2_job_queue SET canceled_by = 'test-user' WHERE id = ANY($1)") + .bind(vec![parent, step]) + .execute(db) + .await?; + } + let job = get_mini_completed_job(&step, W_ID, db).await?.unwrap(); + add_completed_job( + db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + None, + false, + None, + false, + ) + .await?; + + let (status, queued): (String, bool) = sqlx::query_as( + "SELECT status::text, EXISTS (SELECT 1 FROM v2_job_queue WHERE id = $1) \ + FROM v2_job_completed WHERE id = $1", + ) + .bind(step) + .fetch_one(db) + .await?; + assert_eq!(status, if canceled { "canceled" } else { "success" }); + assert!(!queued, "the step left the queue"); + + Ok(sqlx::query_scalar( + "SELECT EXTRACT(EPOCH FROM now() - ping)::float8 FROM v2_job_runtime WHERE id = $1", + ) + .bind(parent) + .fetch_one(db) + .await?) +} + +#[sqlx::test(fixtures("base"))] +async fn a_step_completion_refreshes_its_running_flow_ping( + db: Pool, +) -> anyhow::Result<()> { + let age = complete_step_and_read_parent_ping_age(&db, false).await?; + assert!(age < 60.0, "the parent's ping was refreshed: {age}s old"); + + let age = complete_step_and_read_parent_ping_age(&db, true).await?; + assert!( + age < 60.0, + "a canceled parent's ping was refreshed: {age}s old" + ); + Ok(()) +} diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 5ab870a364..26a46d272e 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + 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"); +} diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index c538cbe6e1..1a973f6fe6 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -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) diff --git a/backend/tests/job_provenance.rs b/backend/tests/job_provenance.rs new file mode 100644 index 0000000000..41de4f6f77 --- /dev/null +++ b/backend/tests/job_provenance.rs @@ -0,0 +1,44 @@ +//! `deployed` gates what job OIDC tokens let external trust policies accept, so a job +//! whose code or path the caller chose must never come out deployed. + +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::job_provenance::job_provenance; + +async fn deployed(db: &Pool, job: &str) -> bool { + job_provenance(db, &Uuid::parse_str(job).unwrap(), "test-workspace") + .await + .unwrap() + .unwrap() + .deployed +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn step_of_deployed_flow_is_deployed(db: Pool) { + let p = job_provenance( + &db, + &Uuid::parse_str("3bb0c0de-0000-4000-8000-000000000002").unwrap(), + "test-workspace", + ) + .await + .unwrap() + .unwrap(); + assert!(p.deployed); + assert_eq!(p.root_path.as_deref(), Some("f/t/agent")); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn step_under_flow_preview_is_not_deployed(db: Pool) { + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000004").await); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn flow_running_another_flows_version_is_not_deployed(db: Pool) { + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000006").await); +} + +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn app_script_is_deployed_only_from_a_deployed_app_run(db: Pool) { + assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000007").await); + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000008").await); +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index c8c154c250..9325f25652 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -513,13 +513,17 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul let (status, secret) = get( &authed_base, &format!("job_signature/{STEP_JOB}/0"), - Some("SECRET_TOKEN_2"), + Some("SECRET_TOKEN"), ) .await; - assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + assert!( + status.is_success(), + "an admin 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) -> 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( diff --git a/backend/tests/path_autocomplete_scope.rs b/backend/tests/path_autocomplete_scope.rs new file mode 100644 index 0000000000..a4f9485952 --- /dev/null +++ b/backend/tests/path_autocomplete_scope.rs @@ -0,0 +1,62 @@ +//! `GET /path_autocomplete/list_paths` returns only the paths the caller can read, +//! and its cache never serves one caller's list to another. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +async fn list_paths(port: u16, token: &str) -> anyhow::Result> { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/path_autocomplete/list_paths" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "list_paths: {}", resp.text().await?); + let body: serde_json::Value = resp.json().await?; + Ok(serde_json::from_value(body["paths"].clone())?) +} + +#[sqlx::test(fixtures("base"))] +async fn test_list_paths_is_scoped_to_caller(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) + VALUES ('test-workspace', 'secret', 'secret', '{}', '{}')", + ) + .execute(&db) + .await?; + for path in [ + "f/secret/hidden", + "u/test-user/private", + "u/test-user-2/mine", + ] { + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description) + VALUES ('test-workspace', $1, 'x', false, '')", + ) + .bind(path) + .execute(&db) + .await?; + } + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The admin's call fills the cache first, so a cache shared across callers + // would hand the admin's list to the non-admin. + assert_eq!( + list_paths(port, "SECRET_TOKEN").await?, + [ + "f/secret/hidden", + "u/test-user-2/mine", + "u/test-user/private" + ] + ); + assert_eq!( + list_paths(port, "SECRET_TOKEN_2").await?, + ["u/test-user-2/mine"] + ); + Ok(()) +} diff --git a/backend/tests/restart_flow_version_scope.rs b/backend/tests/restart_flow_version_scope.rs new file mode 100644 index 0000000000..82e8f8e4e7 --- /dev/null +++ b/backend/tests/restart_flow_version_scope.rs @@ -0,0 +1,135 @@ +//! A restart's `flow_version` is caller-supplied while the restarted job keeps the original +//! job's path, so only versions of that same flow may be restarted on. + +use sqlx::{Pool, Postgres}; +use windmill_common::cache; +use windmill_common::error::Error; +use windmill_common::flow_status::FlowStatus; +use windmill_common::jobs::JobPayload; +use windmill_queue::PushIsolationLevel; + +const WS: &str = "test-workspace"; + +async fn push_restart( + db: &Pool, + completed_job_id: uuid::Uuid, + flow_version: i64, +) -> Result { + let args = std::collections::HashMap::new(); + let (uuid, tx) = windmill_queue::push( + db, + PushIsolationLevel::IsolatedRoot(db.clone()), + WS, + JobPayload::RestartedFlow { + completed_job_id, + step_id: "a".into(), + branch_or_iteration_n: None, + flow_version: Some(flow_version), + branch_chosen: None, + nested: None, + }, + windmill_queue::PushArgs::from(&args), + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await?; + tx.commit().await?; + Ok(uuid) +} + +#[sqlx::test(fixtures("base", "hello"))] +async fn test_restart_rejects_flow_version_of_another_path( + db: Pool, +) -> anyhow::Result<()> { + // A completed run of f/system/hello_flow (version 1443253234253453). + let version = 1443253234253453; + let flow = cache::flow::fetch_version(&db, version).await?; + let completed_job_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, created_by, + permissioned_as, permissioned_as_email, tag) + VALUES ($1, $2, 'flow', 'f/system/hello_flow', $3, 'test-user', 'u/test-user', + 'test@windmill.dev', 'flow')", + ) + .bind(completed_job_id) + .bind(WS) + .bind(version) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, flow_status) + VALUES ($1, $2, 0, 'success', $3)", + ) + .bind(completed_job_id) + .bind(WS) + .bind(serde_json::to_value(FlowStatus::new(flow.value()))?) + .execute(&db) + .await?; + + // f/system/hello_with_nodes_flow's version, in the same workspace. + let err = push_restart(&db, completed_job_id, 1443253234253454) + .await + .expect_err("a version of another flow must be rejected"); + assert!(matches!(err, Error::BadRequest(_)), "{err:?}"); + + // A version at the same path in another workspace. + sqlx::query( + "INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by) + SELECT 'other-workspace', path, summary, description, value, edited_by FROM flow + WHERE workspace_id = $1 AND path = 'f/system/hello_flow'", + ) + .bind(WS) + .execute(&db) + .await?; + let other_ws_version: i64 = sqlx::query_scalar( + "INSERT INTO flow_version (workspace_id, path, schema, value, created_by) + SELECT 'other-workspace', path, schema, value, created_by FROM flow_version WHERE id = $1 + RETURNING id", + ) + .bind(version) + .fetch_one(&db) + .await?; + let err = push_restart(&db, completed_job_id, other_ws_version) + .await + .expect_err("a version from another workspace must be rejected"); + assert!(matches!(err, Error::BadRequest(_)), "{err:?}"); + + // Another version of the restarted flow itself stays allowed. + let same_path_version: i64 = sqlx::query_scalar( + "INSERT INTO flow_version (workspace_id, path, schema, value, created_by) + SELECT workspace_id, path, schema, value, created_by FROM flow_version WHERE id = $1 + RETURNING id", + ) + .bind(version) + .fetch_one(&db) + .await?; + push_restart(&db, completed_job_id, same_path_version).await?; + Ok(()) +} diff --git a/backend/tests/run_lineage_auth.rs b/backend/tests/run_lineage_auth.rs new file mode 100644 index 0000000000..4f154cdc69 --- /dev/null +++ b/backend/tests/run_lineage_auth.rs @@ -0,0 +1,136 @@ +//! `parent_job` / `root_job` on the run endpoints set the lineage a job is identified by +//! (`WM_FLOW_JOB_ID`, `WM_ROOT_FLOW_JOB_ID`, the OIDC token's flow path). Only a job's own +//! `WM_TOKEN` may claim that job or its ancestors, and a workspace admin any job of the +//! workspace; a lineage anyone else names is dropped from the pushed job. + +use reqwest::StatusCode; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +const ADMIN_JOB: &str = "a0000000-0000-0000-0000-000000000001"; +const ROOT_JOB: &str = "a0000000-0000-0000-0000-000000000002"; +const USER_JOB: &str = "a0000000-0000-0000-0000-000000000003"; + +async fn insert_job( + db: &Pool, + id: &str, + user: &str, + email: &str, + root: Option<&str>, +) -> anyhow::Result<()> { + let root = root.map(Uuid::parse_str).transpose()?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, parent_job, root_job, flow_innermost_root_job) + VALUES ($1, 'test-workspace', $2, 'u/' || $2, $3, 'script', 'deno', 'u/x/y', 'deno', + $4, $4, $4)", + ) + .bind(Uuid::parse_str(id)?) + .bind(user) + .bind(email) + .bind(root) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_run_lineage_must_be_the_callers_own(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + "INSERT INTO script (workspace_id, created_by, content, schema, summary, description, + path, hash, language, lock, kind) + VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '', + 'u/test-user-3/probe', 424242, 'deno', '', 'script')", + ) + .execute(&db) + .await?; + insert_job(&db, ADMIN_JOB, "test-user", "test@windmill.dev", None).await?; + insert_job(&db, ROOT_JOB, "test-user-3", "test3@windmill.dev", None).await?; + insert_job( + &db, + USER_JOB, + "test-user-3", + "test3@windmill.dev", + Some(ROOT_JOB), + ) + .await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + let job_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &Uuid::parse_str(USER_JOB)?, + None, + None, + ) + .await?; + + let client = reqwest::Client::new(); + let cases = [ + // a user's own token claims neither someone else's job nor even its own + ( + "SECRET_TOKEN_3", + Some(ADMIN_JOB), + Some(ADMIN_JOB), + None, + None, + ), + ("SECRET_TOKEN_3", Some(USER_JOB), None, None, None), + // what the SDKs send from inside a job: that job and its root flow + ( + &job_token, + Some(USER_JOB), + Some(ROOT_JOB), + Some(USER_JOB), + Some(ROOT_JOB), + ), + ( + &job_token, + Some(USER_JOB), + Some(ADMIN_JOB), + Some(USER_JOB), + None, + ), + ("SECRET_TOKEN", Some(USER_JOB), None, Some(USER_JOB), None), + ]; + for (token, parent, root, kept_parent, kept_root) in cases { + let query = [("parent_job", parent), ("root_job", root)] + .into_iter() + .filter_map(|(k, v)| v.map(|v| format!("{k}={v}"))) + .collect::>() + .join("&"); + let resp = client + .post(format!( + "http://localhost:{}/api/w/test-workspace/jobs/run/p/u/test-user-3/probe?{query}", + server.addr.port() + )) + .bearer_auth(token) + .json(&serde_json::json!({})) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, StatusCode::CREATED, "{query}: {body}"); + let (stored_parent, stored_root): (Option, Option) = + sqlx::query_as("SELECT parent_job, flow_innermost_root_job FROM v2_job WHERE id = $1") + .bind(Uuid::parse_str(&body)?) + .fetch_one(&db) + .await?; + let expected = |id: Option<&str>| id.map(Uuid::parse_str).transpose(); + assert_eq!( + stored_parent, + expected(kept_parent)?, + "parent_job for {query}" + ); + assert_eq!(stored_root, expected(kept_root)?, "root_job for {query}"); + } + Ok(()) +} diff --git a/backend/tests/script_schema_inference.rs b/backend/tests/script_schema_inference.rs new file mode 100644 index 0000000000..083716f8df --- /dev/null +++ b/backend/tests/script_schema_inference.rs @@ -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) -> 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) -> 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, +) -> 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(()) +} diff --git a/backend/tests/wac_approval_urls.rs b/backend/tests/wac_approval_urls.rs index 0cb33e67c0..7883c69b17 100644 --- a/backend/tests/wac_approval_urls.rs +++ b/backend/tests/wac_approval_urls.rs @@ -201,7 +201,10 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> let client = reqwest::Client::new(); let mint = |ws: &str, job: &str, key: &str| { let url = format!("{base}/w/{ws}/jobs/wac_approval_urls/{job}/{key}"); - client.get(url).header("Authorization", "Bearer SECRET_TOKEN").send() + client + .get(url) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() }; assert_eq!( @@ -227,8 +230,15 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> let resp = mint("test-workspace", WAC_JOB, APPROVAL_COLLISION_B).await?; let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "colliding key: {body}"); - assert!(body.contains(APPROVAL_COLLISION_A), "error must name the other key: {body}"); + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "colliding key: {body}" + ); + assert!( + body.contains(APPROVAL_COLLISION_A), + "error must name the other key: {body}" + ); Ok(()) } @@ -236,7 +246,9 @@ async fn wac_approval_urls_mint_guards(db: Pool) -> anyhow::Result<()> /// Colliding keys share one resume_job row and one capability, so the mint that /// records them must let exactly one through however the requests interleave. #[sqlx::test(fixtures("base", "wac_approval_urls"))] -async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool) -> anyhow::Result<()> { +async fn wac_concurrent_colliding_mints_admit_exactly_one( + db: Pool, +) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let base = format!("http://localhost:{}/api", server.addr.port()); @@ -262,6 +274,92 @@ async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool) -> other => anyhow::bail!("unexpected status {other}"), } } - assert_eq!((ok, rejected), (1, 1), "exactly one colliding key may be minted"); + assert_eq!( + (ok, rejected), + (1, 1), + "exactly one colliding key may be minted" + ); + Ok(()) +} + +/// A resume signature skips the step's approval_conditions, so only the suspended job's own +/// run (its `WM_TOKEN`) or a workspace admin may mint one, and a signature made with another +/// workspace's key must not reach a job outside that workspace. +#[sqlx::test(fixtures("base", "wac_approval_urls"))] +async fn resume_signatures_are_bound_to_the_run_and_its_workspace( + db: Pool, +) -> anyhow::Result<()> { + use hmac::Mac; + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + set_jwt_secret().await; + let job_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user-2", + "ephemeral-script", + 300, + "test2@windmill.dev", + &uuid::Uuid::parse_str(WAC_JOB)?, + None, + None, + ) + .await?; + + for route in ["job_signature", "resume_urls"] { + for (token, allowed) in [ + ("SECRET_TOKEN_2", false), + (&job_token, true), + ("SECRET_TOKEN", true), + ] { + let status = client + .get(format!("{base}/w/test-workspace/jobs/{route}/{WAC_JOB}/7")) + .bearer_auth(token) + .send() + .await? + .status(); + assert_eq!( + status.is_success(), + allowed, + "{route} with a {} token: {status}", + if token == job_token { "job" } else { token } + ); + } + } + + let mut mac = hmac::Hmac::::new_from_slice(b"test-key-2")?; + mac.update(uuid::Uuid::parse_str(WAC_JOB)?.as_bytes()); + mac.update(&5u32.to_be_bytes()); + let foreign = hex::encode(mac.finalize().into_bytes()); + for op in ["resume", "cancel"] { + let status = client + .post(format!( + "{base}/w/test-workspace-2/jobs_u/{op}/{WAC_JOB}/5/{foreign}" + )) + .json(&serde_json::json!({})) + .send() + .await? + .status(); + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "{op} from test-workspace-2" + ); + } + let (suspend, resumes): (i32, i64) = sqlx::query_as( + "SELECT suspend, (SELECT count(*) FROM resume_job WHERE job = $1::uuid) + FROM v2_job_queue WHERE id = $1::uuid", + ) + .bind(WAC_JOB) + .fetch_one(&db) + .await?; + assert_eq!( + (suspend, resumes), + (1, 0), + "the job must still be suspended" + ); Ok(()) } diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 98ea87a4e3..b1dea3ae54 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [features] default = [] -bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] +bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config", "windmill-common/aws_auth"] mcp = ["dep:windmill-mcp"] [lib] diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index b223725af2..a9318a1f3a 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -2,6 +2,7 @@ //! //! This module provides: //! - BedrockClient: SDK wrapper with bearer token and IAM credentials auth +//! - OIDC role assumption: exchange a Windmill OIDC token for temporary IAM keys //! - Message/tool conversion: OpenAI format <-> Bedrock Converse API format //! - Stream event parsing: Extract text/tool deltas from Bedrock stream events //! @@ -291,6 +292,187 @@ impl BedrockClient { } } +// ============================================================================ +// OIDC Role Assumption +// ============================================================================ + +pub use windmill_common::auth::aws::AWS_OIDC_AUDIENCE; + +/// Bedrock credential precedence: a bearer API key wins, then a complete pair of +/// explicit IAM keys, then OIDC role assumption, then the ambient environment. +/// Returns the role to assume only when both higher-priority modes are unset, so +/// a resource can carry a role ARN without it ever overriding keys set on it. +pub fn bedrock_oidc_role_to_assume<'a>( + api_key: Option<&str>, + aws_access_key_id: Option<&str>, + aws_secret_access_key: Option<&str>, + oidc_role_arn: Option<&'a str>, +) -> Option<&'a str> { + let has_bearer_token = api_key.is_some_and(|key| !key.is_empty()); + let has_iam_keys = aws_access_key_id.is_some_and(|key| !key.is_empty()) + && aws_secret_access_key.is_some_and(|key| !key.is_empty()); + + if has_bearer_token || has_iam_keys { + return None; + } + + oidc_role_arn.filter(|arn| !arn.is_empty()) +} + +/// Re-assume the role this long before AWS expires the credentials, so a request +/// that starts on a reused set cannot finish holding dead ones. +pub const ASSUMED_ROLE_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(120); + +/// Temporary IAM credentials minted by STS `AssumeRoleWithWebIdentity`. +#[derive(Clone, Debug)] +pub struct AssumedRoleCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expires_at: std::time::SystemTime, +} + +impl AssumedRoleCredentials { + /// Whether these have enough life left to sign another request. + pub fn is_fresh(&self) -> bool { + self.expires_at > std::time::SystemTime::now() + ASSUMED_ROLE_REFRESH_MARGIN + } +} + +/// Assume the resource's OIDC role for a job, reusing `cached` while it is still +/// fresh, and hand back the temporary keys to sign Bedrock requests with. +/// +/// The agent loop calls this once per iteration, so without the reuse a +/// tool-heavy run would mint an OIDC token and call STS on every model turn. +/// Returns `None` when a higher-priority credential is set, so the caller can +/// call it unconditionally. +pub async fn refresh_bedrock_oidc_credentials<'a>( + credentials: &crate::credentials::ProviderCredentials, + cached: &'a mut Option, + client: &windmill_common::client::AuthedClient, + job_id: &uuid::Uuid, +) -> Result, Error> { + let Some(role_arn) = bedrock_oidc_role_to_assume( + credentials.api_key.as_deref(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.oidc_role_arn.as_deref(), + ) else { + return Ok(None); + }; + + // Ahead of the reuse check, so a request never signs against the empty region + // just because an earlier one had already assumed the role. + let region = bedrock_oidc_region(credentials.region.as_deref())?; + + if !cached + .as_ref() + .is_some_and(AssumedRoleCredentials::is_fresh) + { + // A worker holds no OIDC signing key, so it asks the API to mint the + // token for this job; the session name carries the job into CloudTrail. + let id_token = client + .get_id_token(AWS_OIDC_AUDIENCE) + .await + .map_err(|e| Error::internal_err(format!("Failed to get OIDC token: {}", e)))?; + // The whole UUID: "windmill-ai-job-" plus 32 hex is 48 characters, well + // inside AWS's limit, and a truncated one would make two runs + // indistinguishable in CloudTrail. + let session_name = aws_role_session_name("windmill-ai-job", &job_id.simple().to_string()); + *cached = + Some(assume_role_with_oidc_token(role_arn, region, id_token, &session_name).await?); + } + + Ok(cached.as_ref()) +} + +/// Distinguishing digest appended to a session name that had to be truncated. +const SESSION_NAME_DIGEST_LEN: usize = 8; + +/// Build a role session name AWS accepts: it constrains them to +/// `[\w+=,.@-]{2,64}`, and rejects the whole request otherwise. Disallowed +/// characters are replaced rather than dropped. +/// +/// The name is the only thing carrying the caller into the assumed-role ARN and +/// CloudTrail, so an identity too long to fit keeps a prefix plus a digest of the +/// whole thing: two identities sharing a prefix would otherwise be +/// indistinguishable in exactly the audit trail this feature exists to produce. +pub fn aws_role_session_name(prefix: &str, identity: &str) -> String { + let sanitize = |s: &str| -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-') + { + c + } else { + '-' + } + }) + .collect() + }; + + let name = sanitize(&format!("{}-{}", prefix, identity)); + if name.len() <= 64 { + return name; + } + + let digest = &windmill_common::utils::calculate_hash(identity)[..SESSION_NAME_DIGEST_LEN]; + let kept = 64 - SESSION_NAME_DIGEST_LEN - 1; + format!("{}-{}", &name[..kept], digest) +} + +/// The region an OIDC role assumption runs in. +/// +/// The assumption resolves to explicit IAM keys, and the SDK client built from +/// those has no ambient-region fallback the way [`BedrockClient::from_env`] does, +/// so a resource that assumes a role has to name its region. Callers check this +/// before minting the OIDC token, so a misconfigured resource never issues an +/// identity token it is only going to throw away. +pub fn bedrock_oidc_region(region: Option<&str>) -> Result<&str, Error> { + region.filter(|r| !r.is_empty()).ok_or_else(|| { + Error::BadRequest( + "AWS Bedrock resources that assume a role through OIDC must set a region".to_string(), + ) + }) +} + +/// Exchange a Windmill OIDC token for temporary IAM credentials on `role_arn`. +pub async fn assume_role_with_oidc_token( + role_arn: &str, + region: &str, + id_token: String, + session_name: &str, +) -> Result { + use windmill_common::auth::aws::{ + get_assume_role_with_web_identity_fluent_builder, GetAuthenticationOutput, OidcAuth, + }; + + let oidc_auth = OidcAuth { region: Some(region.to_string()), role_arn: role_arn.to_string() }; + + let output = + get_assume_role_with_web_identity_fluent_builder(&oidc_auth, id_token, Some(session_name)) + .await? + .send() + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to assume AWS role {} with Windmill's OIDC token: {}", + role_arn, + format_bedrock_error(&e) + )) + })?; + + let credentials = output.get_credentials()?; + + Ok(AssumedRoleCredentials { + access_key_id: credentials.access_key_id.clone(), + secret_access_key: credentials.secret_access_key.clone(), + session_token: credentials.session_token.clone(), + expires_at: std::time::UNIX_EPOCH + + std::time::Duration::from_secs(credentials.expiration.secs().max(0) as u64), + }) +} + // ============================================================================ // Error Formatting // ============================================================================ @@ -1000,6 +1182,82 @@ mod tests { use aws_sdk_bedrockruntime::types::ReasoningContentBlock; use serde_json::value::RawValue; + #[test] + fn oidc_role_yields_only_to_bearer_and_complete_iam_keys() { + let arn = Some("arn:aws:iam::1:role/bedrock"); + + assert_eq!( + bedrock_oidc_role_to_assume(None, None, None, arn), + arn, + "nothing else set: assume the role rather than fall through to the environment" + ); + assert_eq!( + bedrock_oidc_role_to_assume(Some("key"), None, None, arn), + None + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, Some("id"), Some("secret"), arn), + None + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, Some("id"), None, arn), + arn, + "half a key pair is not usable IAM credentials, so the role still wins" + ); + // Cleared resource fields arrive as empty strings, not as absent ones. + assert_eq!( + bedrock_oidc_role_to_assume(Some(""), Some(""), Some(""), arn), + arn + ); + assert_eq!( + bedrock_oidc_role_to_assume(None, None, None, Some("")), + None + ); + } + + #[test] + fn credentials_stop_being_fresh_before_aws_expires_them() { + let at = |d: std::time::Duration| AssumedRoleCredentials { + access_key_id: String::new(), + secret_access_key: String::new(), + session_token: String::new(), + expires_at: std::time::SystemTime::now() + d, + }; + // Reused only while there is still margin left to finish a request. + assert!(at(ASSUMED_ROLE_REFRESH_MARGIN * 2).is_fresh()); + assert!(!at(ASSUMED_ROLE_REFRESH_MARGIN / 2).is_fresh()); + } + + #[test] + fn role_session_name_stays_within_what_aws_accepts() { + // A whole job UUID fits, so CloudTrail names the exact run. + assert_eq!( + aws_role_session_name("windmill-ai-job", "0a1b2c3d4e5f60718293a4b5c6d7e8f9"), + "windmill-ai-job-0a1b2c3d4e5f60718293a4b5c6d7e8f9" + ); + // Usernames and emails routinely carry characters AWS rejects. + assert_eq!( + aws_role_session_name("windmill-ai-copilot", "ada/lovelace (admin)"), + "windmill-ai-copilot-ada-lovelace--admin-" + ); + + // Identities too long to fit keep a digest, so a shared prefix does not + // collapse two callers into one CloudTrail identity. + let a = aws_role_session_name("windmill-ai-copilot", &format!("{}a", "x".repeat(60))); + let b = aws_role_session_name("windmill-ai-copilot", &format!("{}b", "x".repeat(60))); + assert_ne!(a, b); + for name in [&a, &b] { + assert_eq!(name.len(), 64); + assert!(name.chars().all(|c| c.is_ascii_alphanumeric() + || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-'))); + } + + // Sanitising before truncating keeps every char one byte, so the cap is + // never applied mid-character. + let unicode = aws_role_session_name("windmill-ai-copilot", &"é".repeat(100)); + assert_eq!(unicode.len(), 64); + } + fn text_message(role: &str, content: &str) -> OpenAIMessage { OpenAIMessage { role: role.to_string(), diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs index c9502bdbb2..5a779938ed 100644 --- a/backend/windmill-ai/src/credentials.rs +++ b/backend/windmill-ai/src/credentials.rs @@ -19,6 +19,19 @@ pub struct ProviderCredentials { pub aws_access_key_id: Option, pub aws_secret_access_key: Option, pub aws_session_token: Option, + /// AWS IAM role to assume through Windmill's OIDC provider. Only consulted + /// when no bearer key and no explicit IAM keys are set — see + /// [`crate::ai_bedrock::bedrock_oidc_role_to_assume`]. The API proxy + /// exchanges it for temporary keys before it builds the request; the worker + /// exchanges it at the Bedrock call, because only the API can sign an OIDC + /// token. + /// + /// The two paths therefore present different tokens to AWS: the proxy a + /// workspace claim with `sub = "::"`, the worker the job + /// claim `/oidc/token` issues, `sub = "::::::"`. + /// A role trust policy that conditions on `sub` has to admit both, or only + /// one of the copilot and the AI agent step will be able to assume the role. + pub oidc_role_arn: Option, pub platform: AIPlatform, pub custom_headers: HashMap, } diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 0bb7316dcb..d274d91fa2 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -858,6 +858,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 8320ef5661..ce37e45df7 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -780,6 +780,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index af9ba3ff9f..908f007978 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -184,6 +184,7 @@ mod parity_tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: kase.platform.clone(), custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index c672aa1d42..3af8f88a1d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -263,6 +263,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 12e06a35d8..ac65f637e2 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -245,6 +245,12 @@ pub struct ProviderResource { deserialize_with = "empty_string_as_none" )] pub aws_session_token: Option, + #[serde( + alias = "oidcRoleArn", + default, + deserialize_with = "empty_string_as_none" + )] + pub oidc_role_arn: Option, /// Platform (standard or google_vertex_ai) #[serde(default)] pub platform: AIPlatform, @@ -311,6 +317,7 @@ impl ProviderWithResource { aws_access_key_id: self.resource.aws_access_key_id.clone(), aws_secret_access_key: self.resource.aws_secret_access_key.clone(), aws_session_token: self.resource.aws_session_token.clone(), + oidc_role_arn: self.resource.oidc_role_arn.clone(), platform: self.resource.platform.clone(), custom_headers: self.resource.headers.clone(), }) diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index bb1586db68..12ad5f2654 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -12,8 +12,12 @@ pub mod ee; pub mod ee_oss; pub mod scopes; -use axum::extract::{FromRequestParts, OptionalFromRequestParts}; +use axum::extract::{FromRequestParts, OptionalFromRequestParts, Path, Request, State}; +use axum::middleware::Next; +use axum::response::Response; +use axum::Extension; use http::request::Parts; +use std::collections::HashMap; use windmill_audit::audit_oss::AuditAuthorable; use windmill_common::{ @@ -26,6 +30,7 @@ use windmill_common::{ jobs::JobTriggerKind, triggers::TriggerMetadata, users::username_to_permissioned_as, + workspaces::{check_operator_can_manage, ManageKind}, DB, }; @@ -940,6 +945,41 @@ pub async fn maybe_refresh_folders( } } +// ------------ Router-level gate for withdrawable operator rights ------------ + +/// Refuses every write (anything but GET/HEAD/OPTIONS) under the router it layers, unless the +/// workspace still grants operators `kind`. It sits on the router rather than in each handler so +/// that a route added later is covered without its author doing anything; read +/// `docs/operator-write-rights.md` before putting a new route under one of these routers. +pub async fn gate_operator_writes( + State(kind): State, + Extension(db): Extension, + Path(params): Path>, + request: Request, + next: Next, +) -> Result { + if matches!( + *request.method(), + http::Method::GET | http::Method::HEAD | http::Method::OPTIONS + ) { + return Ok(next.run(request).await); + } + + // Resolved here rather than taken as an extractor so that reads, which are the traffic under + // these routers, do not pay for it. + let (mut parts, body) = request.into_parts(); + let authed = >::from_request_parts(&mut parts, &()).await?; + + // Absent only if this is layered outside `/w/{workspace_id}`, which is a wiring mistake: + // refuse rather than let the write through unchecked. + let w_id = params.get("workspace_id").ok_or_else(|| { + Error::internal_err("operator write gate layered outside a workspaced router".to_string()) + })?; + check_operator_can_manage(&db, w_id, authed.is_operator, kind).await?; + + Ok(next.run(Request::from_parts(parts, body)).await) +} + // ------------ FromRequestParts impls (direct call to auth module) ------------ impl FromRequestParts for ApiAuthed diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 07b8004d83..f7c3398adc 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -15,6 +15,7 @@ use windmill_api_auth::require_owner_of_path; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::scripts::ScriptHash; +use windmill_common::workspaces::{check_operator_can_manage, ManageKind}; use windmill_common::DB; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -44,10 +45,18 @@ fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> { "raw_app" => Some("raw_apps"), "resource" => Some("resources"), "variable" => Some("variables"), - "schedule" => Some("schedules"), + _ => manage_kind_for_acl_kind(kind).map(|k| k.audit_prefix()), + } +} + +/// The kinds whose writes a workspace can withdraw from operators. `/acls` serves every kind, so it +/// cannot sit under the routers that layer the gate — see `docs/operator-write-rights.md`. +fn manage_kind_for_acl_kind(kind: &str) -> Option { + match kind { + "schedule" => Some(ManageKind::Schedules), "http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger" | "postgres_trigger" | "mqtt_trigger" | "amqp_trigger" | "gcp_trigger" - | "azure_trigger" | "sqs_trigger" | "email_trigger" => Some("triggers"), + | "azure_trigger" | "sqs_trigger" | "email_trigger" => Some(ManageKind::Triggers), _ => None, } } @@ -107,6 +116,10 @@ async fn add_granular_acl( return Err(Error::BadRequest("Invalid kind".to_string())); } + if let Some(manage_kind) = manage_kind_for_acl_kind(kind) { + check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?; + } + let identifier = if kind == "group_" || kind == "folder" || kind == "volume" { "name" } else { @@ -354,6 +367,10 @@ async fn remove_granular_acl( return Err(Error::BadRequest("Invalid kind".to_string())); } + if let Some(manage_kind) = manage_kind_for_acl_kind(kind) { + check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?; + } + if !authed.is_admin { if kind == "folder" { crate::folders::require_is_owner(&authed, path)?; diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index 3aee7e766c..69dbaeaaa2 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -16,6 +16,7 @@ deno_core = ["windmill-test-utils/deno_core"] mcp = ["windmill-test-utils/mcp", "dep:rmcp"] run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"] postgres_trigger = ["windmill-test-utils/postgres_trigger"] +http_trigger = ["windmill-test-utils/http_trigger"] [dependencies] windmill-test-utils.workspace = true @@ -47,3 +48,4 @@ aws-sdk-sqs = { workspace = true, optional = true } base64 = { workspace = true, optional = true } axum.workspace = true rmcp = { workspace = true, optional = true } +serial_test = "3" diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs new file mode 100644 index 0000000000..ae1979f2fb --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -0,0 +1,215 @@ +use serde_json::json; +use serial_test::serial; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_operator_rights_cache; +use windmill_test_utils::*; + +/// Every test here must be `#[serial]`. The rights cache is process-global and keyed by workspace +/// id alone, while `sqlx::test` gives each test its own database under this same id — so two +/// running at once can answer each other's reads from one cached entry, and a withdrawal in one +/// database reads as granted in the other. +const WS: &str = "test-workspace"; + +fn operator_client() -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() +} + +fn new_schedule(path: &str) -> serde_json::Value { + json!({ + "path": path, + "schedule": "0 0 * * * *", + "timezone": "UTC", + "script_path": "u/operator/some_script", + "is_flow": false, + "enabled": false, + }) +} + +/// Saves `body` as the workspace's operator settings and drops the local cache entry, which a test +/// flipping the setting out of band has to do itself — the notify trigger only reaches other +/// processes. +async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result { + let resp = reqwest::Client::new() + .post(format!("{api}/workspaces/operator_settings")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&body) + .send() + .await?; + let status = resp.status().as_u16(); + invalidate_operator_rights_cache(WS); + Ok(status) +} + +/// Granted unless withdrawn, and a payload that omits the key leaves the stored value alone. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_operator_manage_rights(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + // Never configured: the right is held. + let resp = c + .post(format!("{api}/schedules/create")) + .json(&new_schedule("u/operator/sched_default")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // Both polarities: a 403 alone would equally be the route's own ownership check refusing. + let share = async || -> anyhow::Result<(u16, String)> { + let resp = c + .post(format!("{api}/acls/add/schedule/u/operator/sched_default")) + .json(&json!({"owner": "u/alice", "write": true})) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) + }; + let (status, body) = share().await?; + assert_eq!(status, 200, "{body}"); + + // An admin withdraws it. + assert_eq!( + set_settings(&api, json!({"manage_schedules": false})).await?, + 200 + ); + + // 403 is a contract, not an incidental status - see `check_operator_can_manage`. + let resp = c + .post(format!("{api}/schedules/create")) + .json(&new_schedule("u/operator/sched_withdrawn")) + .send() + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + let (status, body) = share().await?; + assert_eq!(status, 403, "{body}"); + + // The gate refuses on `is_operator` alone, so one over-broad condition costs every admin their + // schedule and trigger writes. The admin call above proves nothing: its route is not gated. + let resp = reqwest::Client::new() + .post(format!("{api}/schedules/create")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&new_schedule("u/admin/sched_admin")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // A payload omitting the key must not restore it. + assert_eq!(set_settings(&api, json!({"runs": true})).await?, 200); + + let resp = c + .post(format!("{api}/schedules/create")) + .json(&new_schedule("u/operator/sched_still_withdrawn")) + .send() + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + // Nor withdraw a stored grant. + assert_eq!( + set_settings(&api, json!({"manage_schedules": true})).await?, + 200 + ); + assert_eq!(set_settings(&api, json!({"runs": true})).await?, 200); + + let resp = c + .post(format!("{api}/schedules/create")) + .json(&new_schedule("u/operator/sched_restored")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + Ok(()) +} + +/// The trigger gate is a layer over the whole trigger router rather than a check inside each +/// handler, because a trigger kind may register routes of its own beside the shared CRUD ones. +/// Bulk HTTP creation is exactly that: it inserts directly and never enters the shared create +/// handler, so a per-handler check missed it. Asserting both polarities is what proves the route +/// is reached and gated rather than merely absent - a route that did not exist would answer 404, +/// not 403. +#[cfg(feature = "http_trigger")] +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_manage_triggers_covers_a_route_outside_the_shared_handler( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + let bulk = json!([{ + "path": "u/operator/bulk", + "script_path": "u/operator/some_script", + "is_flow": false, + "route_path": "bulk_route", + "request_type": "async", + "authentication_method": "none", + "http_method": "post", + "is_static_website": false, + // Non-admins may only create routes under the workspace prefix. + "workspaced_route": true, + }]); + + let create_many = async || -> anyhow::Result<(u16, String)> { + let resp = c + .post(format!("{api}/http_triggers/create_many")) + .json(&bulk) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) + }; + + // Capture is a separate router with its own layer, so a reshuffle of the nesting can drop it. + let set_capture = async || -> anyhow::Result<(u16, String)> { + let resp = c + .post(format!("{api}/capture/set_config")) + .json(&json!({ + "trigger_kind": "webhook", + "path": "u/operator/some_script", + "is_flow": false, + })) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) + }; + + // Held by default: the route is reached and does its own work. + let (status, body) = create_many().await?; + assert_eq!(status, 201, "{body}"); + let (status, body) = set_capture().await?; + assert_eq!(status, 200, "{body}"); + + assert_eq!( + set_settings(&api, json!({"manage_triggers": false})).await?, + 200 + ); + + let (status, body) = create_many().await?; + assert_eq!(status, 403, "{body}"); + let (status, body) = set_capture().await?; + assert_eq!(status, 403, "{body}"); + + // Moving captures re-points existing configs between runnables, so it is gated too; the + // builders skip it while the right is withdrawn. + let resp = c + .post(format!("{api}/capture/move/script/u/operator/draft_path")) + .json(&json!({"new_path": "u/operator/some_script"})) + .send() + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 53648ec759..9c167ef70c 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -566,6 +566,113 @@ async fn test_resource_value_cache_is_identity_scoped(db: Pool) -> any Ok(()) } +/// A token scoped to one resource must not read, through the `$var:`/`$res:` references it +/// writes into that resource, what its scopes would refuse to read directly. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_interpolated_references_need_the_token_scopes( + db: Pool, +) -> 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"); + + let resp = authed(client().post(format!("{base}/variables/create"))) + .json(&json!({"path": "u/test-user/secret", "value": "CANARY", "is_secret": true, "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let resp = authed(client().post(format!("{base}/resources/create"))) + .json(&json!({"path": "u/test-user/other", "value": {"pw": "OTHER"}, "resource_type": "object", "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + + let mint = |scopes: serde_json::Value| async move { + let resp = + authed(client().post(format!("http://localhost:{port}/api/users/tokens/create"))) + .json( + &json!({"label": "scoped", "scopes": scopes, "workspace_id": "test-workspace"}), + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + resp.text().await.unwrap() + }; + let probe_only = mint(json!(["resources:write:u/test-user/probe"])).await; + let with_var = mint(json!([ + "resources:read:u/test-user/probe", + "variables:read:u/test-user/secret" + ])) + .await; + + let read_probe = |value: serde_json::Value, token: String| { + let base = base.clone(); + let probe_only = probe_only.clone(); + async move { + let resp = client() + .post(format!("{base}/resources/create?update_if_exists=true")) + .bearer_auth(&probe_only) + .json(&json!({"path": "u/test-user/probe", "value": value, "resource_type": "object", "description": ""})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let resp = client() + .get(format!( + "{base}/resources/get_value_interpolated/u/test-user/probe" + )) + .bearer_auth(&token) + .send() + .await + .unwrap(); + (resp.status().as_u16(), resp.text().await.unwrap()) + } + }; + + for value in [ + json!({"v": "$var:u/test-user/secret"}), + json!({"v": "$jsonvar:u/test-user/secret"}), + json!({"v": ["$res:u/test-user/other"]}), + ] { + let (status, body) = read_probe(value.clone(), probe_only.clone()).await; + assert_eq!(status, 403, "{value}: {body}"); + assert!( + !body.contains("CANARY") && !body.contains("OTHER"), + "{body}" + ); + } + + let (status, body) = + read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await; + assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#)); + + // The resource's own linked secrets (at its path, or `_`) are part of it. + for (path, value) in [ + ("u/test-user/probe", "LINKED"), + ("u/test-user/probe_key", "KEY"), + ] { + let resp = authed(client().post(format!("{base}/variables/create"))) + .json(&json!({"path": path, "value": value, "is_secret": true, "description": ""})) + .send() + .await?; + assert_eq!(resp.status(), 201); + } + let (status, body) = read_probe( + json!({"pw": "$var:u/test-user/probe", "key": "$var:u/test-user/probe_key"}), + probe_only.clone(), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + serde_json::from_str::(&body)?, + json!({"pw": "LINKED", "key": "KEY"}) + ); + + Ok(()) +} + /// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is /// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the /// placeholder is left unresolved (caching that would serve a stale placeholder to a later job diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs new file mode 100644 index 0000000000..0132a98a61 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies.rs @@ -0,0 +1,57 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +#[allow(unused_imports)] +use windmill_test_utils::*; + +/// An admin of one workspace must not be able to write dependency files into another +/// by naming it in the request body. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_rejects_body_workspace_other_than_path( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // test-user-2 is a plain user of test-workspace and an admin of its own workspace. + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('own-workspace', 'own-workspace', 'test-user-2')") + .execute(&db) + .await?; + sqlx::query("INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('own-workspace', 'test2@windmill.dev', 'test-user-2', true, 'Admin')") + .execute(&db) + .await?; + + let (_client, port, _server) = init_client(db.clone()).await; + + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/own-workspace/workspace_dependencies/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ + "workspace_id": "test-workspace", + "language": "python3", + "content": "requests==2.28.0" + })) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "expected the mismatched body to be rejected, got {body}" + ); + assert!( + body.contains("does not match"), + "expected the workspace mismatch rejection, got {body}" + ); + + let written: i64 = sqlx::query_scalar("SELECT count(*) FROM workspace_dependencies") + .fetch_one(&db) + .await?; + assert_eq!( + written, 0, + "no dependency file may be written in either workspace" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 29b8a0a611..a159bf6090 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -1218,3 +1218,49 @@ async fn test_edit_auto_invite_preserves_instance_groups(db: Pool) -> Ok(()) } + +/// Admin and operator are one role stored as two flags: granting one must clear the other, and a +/// request asking for both is refused, on update and on add. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +async fn test_admin_and_operator_are_exclusive(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/test-workspace"); + let flags = async || -> anyhow::Result<(bool, bool)> { + Ok(sqlx::query_as( + "SELECT is_admin, operator FROM usr WHERE workspace_id = 'test-workspace' AND username = 'operator'", + ) + .fetch_one(&db) + .await?) + }; + + let resp = authed(client().post(format!("{api}/users/update/operator"))) + .json(&json!({"is_admin": true})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!(flags().await?, (true, false)); + + let resp = authed(client().post(format!("{api}/users/update/operator"))) + .json(&json!({"operator": true})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!(flags().await?, (false, true)); + + let resp = authed(client().post(format!("{api}/users/update/operator"))) + .json(&json!({"is_admin": true, "operator": true})) + .send() + .await?; + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + assert_eq!(flags().await?, (false, true)); + + let resp = authed(client().post(format!("{api}/workspaces/add_user"))) + .json(&json!({"email": "both@windmill.dev", "is_admin": true, "operator": true})) + .send() + .await?; + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 9ad7189434..1a750c38ba 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -95,6 +95,83 @@ pub async fn check_tag_as_written_available_for_workspace( } } +/// Clears a `parent_job` / `root_job` the caller cannot claim. They identify the pushed job +/// (`WM_FLOW_JOB_ID`, `WM_ROOT_FLOW_JOB_ID`, the OIDC token's flow path), so only a job's own +/// `WM_TOKEN` may name that job or its ancestors, and a workspace admin any job of the workspace. +/// Cleared rather than refused: the SDKs send them from inside a job whatever token they hold. +pub async fn drop_unclaimable_run_lineage( + db: &DB, + w_id: &str, + run_query: &mut RunJobQuery, + authed: &ApiAuthed, +) -> error::Result<()> { + let referenced: Vec = [run_query.parent_job, run_query.root_job] + .into_iter() + .flatten() + .collect(); + let unclaimable = unclaimable_run_lineage(db, w_id, referenced, authed).await?; + for field in [&mut run_query.parent_job, &mut run_query.root_job] { + if field.is_some_and(|id| unclaimable.contains(&id)) { + tracing::warn!( + "ignoring parent_job/root_job {field:?} that {} cannot claim in {w_id}", + authed.username + ); + *field = None; + } + } + Ok(()) +} + +/// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the +/// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for +/// a workspace admin anything outside the workspace. +pub async fn unclaimable_run_lineage( + db: &DB, + w_id: &str, + mut referenced: Vec, + authed: &ApiAuthed, +) -> error::Result> { + referenced.sort(); + referenced.dedup(); + if let Some(token_job) = authed.job_id { + referenced.retain(|id| *id != token_job); + if !referenced.is_empty() { + if let Some(lineage) = sqlx::query!( + "SELECT parent_job, root_job, flow_innermost_root_job FROM v2_job + WHERE id = $1 AND workspace_id = $2", + token_job, + w_id + ) + .fetch_optional(db) + .await? + { + let ancestors = [ + lineage.parent_job, + lineage.root_job, + lineage.flow_innermost_root_job, + ]; + referenced.retain(|id| !ancestors.contains(&Some(*id))); + } + } + } + if referenced.is_empty() { + return Ok(referenced); + } + let in_workspace = if authed.is_admin { + sqlx::query_scalar!( + "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", + &referenced, + w_id, + ) + .fetch_all(db) + .await? + } else { + vec![] + }; + referenced.retain(|id| !in_workspace.contains(id)); + Ok(referenced) +} + #[cfg(feature = "enterprise")] pub async fn check_license_key_valid() -> error::Result<()> { use windmill_common::ee_oss::LICENSE_KEY_VALID; @@ -776,6 +853,8 @@ pub async fn run_flow<'c>( bool, Option>, )> { + let mut run_query = run_query; + drop_unclaimable_run_lineage(db, w_id, &mut run_query, authed).await?; let on_behalf_of = flow_version_info.on_behalf_of(w_id, &db).await?; let FlowVersionInfo { version, @@ -1017,6 +1096,8 @@ pub async fn push_script_job_by_path_into_queue<'c>( let script_path = script_path.to_path(); check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) = diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 8b34512872..72353c6f1b 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -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 diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 51f2619805..42617b249b 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -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 { + 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, 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::>(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>>( + "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 @@ -2734,19 +2858,7 @@ async fn create_script_internal<'c>( tracing::info!("creating script {hash:?} at path {script_path} on workspace {w_id}",); } if needs_lock_gen { - let tag = if ns.dedicated_worker.is_some_and(|x| x) { - Some(windmill_common::worker::dedicated_worker_tag( - &w_id, &ns.path, - )) - } else if ns.tag.as_ref().is_some_and(|x| x.contains("$args[")) { - None - } else if lang == ScriptLang::Bunnative { - // if a custom tag is set for a bunnative script, this prevents the custom tag to be used for the dependency job - // forcing the bundling to run on a worker with the bun tag - None - } else { - ns.tag - }; + let tag = windmill_common::scripts::dependency_job_tag(ns.tag, &lang); let mut args: HashMap> = HashMap::new(); if let Some(dm) = ns.deployment_message { diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e8d8a7124c..ca93d3c682 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -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) -> 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, + _authed: ApiAuthed, +) -> JsonResult { + 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( @@ -291,7 +303,9 @@ pub async fn test_email( use windmill_object_store::ObjectSettings; #[cfg(feature = "parquet")] -use windmill_object_store::build_object_store_from_settings; +use windmill_object_store::{ + build_object_store_from_settings, build_public_object_store_from_settings, +}; #[cfg(feature = "parquet")] pub async fn test_s3_bucket( @@ -326,9 +340,15 @@ pub async fn test_s3_bucket( })?; } - let client = build_object_store_from_settings(test_s3_bucket, Some(&db)) - .await? - .store; + // The restricted client re-judges every address it connects to: the checks above resolve the + // endpoint separately from the connect, which a rebinding name answers differently. + let client = if restrict { + build_public_object_store_from_settings(test_s3_bucket).await? + } else { + build_object_store_from_settings(test_s3_bucket, Some(&db)) + .await? + .store + }; let run = async { let mut list = client.list(Some( @@ -534,7 +554,7 @@ async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> { // Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding // attempts (a name resolving to both a public and a private address). for addr in addrs { - if is_forbidden_ip(addr.ip()) { + if windmill_common::ssrf::is_private_ip(&addr.ip()) { // The resolved address stays out of the message: it is the server's resolver's // answer, and this message is only ever shown to the caller being constrained. return Err(error::Error::NotAuthorized(format!( @@ -587,51 +607,6 @@ fn extract_host(endpoint: &str) -> Option { } } -#[cfg(feature = "parquet")] -fn is_forbidden_ip(ip: std::net::IpAddr) -> bool { - use std::net::{IpAddr, Ipv4Addr}; - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_multicast() - || v4.octets()[0] == 0 // 0.0.0.0/8 - || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT - } - IpAddr::V6(v6) => { - // Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible - // ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g. - // 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network. - let seg = v6.segments(); - let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0]; - let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0]; - if let Some(v4) = v6.to_ipv4_mapped() { - return is_forbidden_ip(IpAddr::V4(v4)); - } - if is_v4_compatible || is_nat64 { - let embedded = Ipv4Addr::new( - (seg[6] >> 8) as u8, - (seg[6] & 0xff) as u8, - (seg[7] >> 8) as u8, - (seg[7] & 0xff) as u8, - ); - if is_forbidden_ip(IpAddr::V4(embedded)) { - return true; - } - } - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local - || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local - } - } -} - #[cfg(feature = "parquet")] async fn get_object_storage_usage( Extension(db): Extension, @@ -1216,6 +1191,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(()) @@ -2564,8 +2545,7 @@ mod tests { #[cfg(all(test, feature = "parquet"))] mod object_storage_test_hardening { - use super::{extract_host, is_forbidden_ip, validate_object_storage_test}; - use std::net::IpAddr; + use super::{extract_host, validate_object_storage_test}; use windmill_object_store::ObjectSettings; // IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host` @@ -2626,40 +2606,6 @@ mod object_storage_test_hardening { } } - fn ip(s: &str) -> IpAddr { - s.parse().unwrap() - } - - #[test] - fn forbids_internal_ips() { - for s in [ - "127.0.0.1", // loopback - "169.254.169.254", // cloud metadata (link-local) - "10.0.0.5", // private - "172.16.3.4", // private - "192.168.1.10", // private - "0.0.0.0", // unspecified - "100.64.0.1", // CGNAT - "::1", // IPv6 loopback - "fe80::1", // IPv6 link-local - "fc00::1", // IPv6 unique local - "::ffff:127.0.0.1", // IPv4-mapped loopback - "::ffff:169.254.169.254", // IPv4-mapped metadata - "::169.254.169.254", // IPv4-compatible metadata - "64:ff9b::169.254.169.254", // NAT64-embedded metadata - "64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form) - ] { - assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden"); - } - } - - #[test] - fn allows_public_ips() { - for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] { - assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed"); - } - } - #[test] fn extracts_host_from_endpoint() { let cases = [ diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index a1b1054a25..8d1242befa 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -2414,6 +2414,18 @@ pub async fn update_workspace_user_internal( tx: &mut Transaction<'_, Postgres>, authed: Option<&ApiAuthed>, // None for system operations ) -> Result<()> { + // The role is one choice stored as two flags. Granting one clears the other, or a request + // setting only `is_admin` leaves a user shown as admin but refused as an operator. + let (is_admin, operator) = match (is_admin, operator) { + (Some(true), Some(true)) => { + return Err(Error::BadRequest( + "A user cannot be both admin and operator".to_string(), + )) + } + (Some(true), _) => (Some(true), Some(false)), + (_, Some(true)) => (Some(false), Some(true)), + flags => flags, + }; if let Some(a) = is_admin { sqlx::query_scalar!( "UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3", diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8ebd4eeb26..185fe17b25 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -598,6 +598,17 @@ pub struct NewWorkspaceUser { pub operator: bool, } +/// The role is one choice stored as two flags; both set would show as admin in the UI while the +/// server refuses the user as an operator. +fn reject_admin_and_operator(is_admin: bool, operator: bool) -> Result<()> { + if is_admin && operator { + return Err(Error::BadRequest( + "A user cannot be both admin and operator".to_string(), + )); + } + Ok(()) +} + // New format for error handler (grouped) #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -3235,6 +3246,9 @@ pub(crate) async fn pg_dump_database( if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } + if let Some(options) = pg_db.non_empty_options() { + cmd.env("PGOPTIONS", options); + } let output = cmd .output() @@ -3372,6 +3386,9 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } + if let Some(options) = pg_db.non_empty_options() { + cmd.env("PGOPTIONS", options); + } cmd } @@ -10265,6 +10282,7 @@ async fn invite_user( Json(mut nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; + reject_admin_and_operator(nu.is_admin, nu.operator)?; #[cfg(not(feature = "enterprise"))] if w_id == "admins" { @@ -10412,6 +10430,8 @@ async fn add_user( Path(w_id): Path, Json(mut nu): Json, ) -> Result<(StatusCode, String)> { + reject_admin_and_operator(nu.is_admin, nu.operator)?; + #[cfg(not(feature = "enterprise"))] if w_id == "admins" { return Err(Error::BadRequest( @@ -10992,6 +11012,14 @@ struct ChangeOperatorSettings { folders: bool, #[serde(default)] workers: bool, + /// Writes operators may perform unless withdrawn, so `None` (key absent) must mean "leave as + /// stored" rather than a value: the row is merged, not overwritten, and this endpoint takes + /// whole-object payloads from git-sync files that predate the key. Defaulting either way here + /// would make an older file silently withdraw or restore the right on every pull. + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_schedules: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_triggers: Option, } async fn update_operator_settings( @@ -11006,16 +11034,32 @@ async fn update_operator_settings( let settings_json = serde_json::json!(settings); + // Merged for the `Option` fields above; the visibility flags always serialize, so for them + // this is a plain overwrite. sqlx::query!( - "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", + "UPDATE workspace_settings + SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1 + WHERE workspace_id = $2", settings_json, &w_id ) .execute(&mut *tx) .await?; + audit_log( + &mut *tx, + &authed, + "workspaces.update_operator_settings", + ActionKind::Update, + &w_id, + None, + Some([("operator_settings", settings_json.to_string().as_str())].into()), + ) + .await?; tx.commit().await?; + windmill_common::workspaces::invalidate_operator_rights_cache(&w_id); + // Trigger git sync for operator settings changes handle_deployment_metadata( &authed.email, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0dd9f8620e..075ced45a9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.817.0 + version: 1.819.0 title: Windmill API contact: @@ -1762,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) @@ -10665,6 +10685,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10704,6 +10727,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10846,6 +10872,8 @@ paths: text/plain: schema: type: string + "400": + description: the body's workspace_id does not match the workspace in the path /w/{workspace}/workspace_dependencies/archive/{language}: post: @@ -13500,7 +13528,7 @@ paths: Returns the flat list of all item paths visible to the caller across scripts, flows, apps, raw apps, variables, and resources. Intended to feed an entirely client-side path autocomplete UI: the frontend fetches - once (server caches per workspace for 60s) and performs all prefix/segment + once (server caches per caller for 60s) and performs all prefix/segment computation locally. Capped at 20,000 paths (5,000 per table). operationId: listPathAutocompletePaths tags: @@ -16284,6 +16312,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 @@ -29455,6 +29506,7 @@ components: properties: workspace_id: type: string + description: must equal the workspace in the request path language: $ref: "#/components/schemas/ScriptLang" name: @@ -29722,6 +29774,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: @@ -31142,6 +31203,7 @@ components: - "jobs" - "jobs.cancel" - "jobs.force_cancel" + - "jobs.run_now" - "jobs.disapproval" - "jobs.delete" - "account.delete" @@ -37200,6 +37262,12 @@ components: workers: type: boolean description: Whether operators can view workers page + manage_schedules: + type: boolean + description: Whether operators can create, edit and delete schedules. Granted unless withdrawn; omitting the field leaves the stored value unchanged. + manage_triggers: + type: boolean + description: Whether operators can create, edit and delete triggers. Granted unless withdrawn; omitting the field leaves the stored value unchanged. WorkspaceComparison: type: object diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 16b4b17141..3255b629da 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -120,8 +120,23 @@ lazy_static::lazy_static! { } +#[cfg(feature = "bedrock")] +lazy_static::lazy_static! { + /// Temporary IAM credentials from a Bedrock OIDC role assumption, keyed by + /// (workspace, role ARN, user email). The email is the identity the OIDC + /// token is minted for, so unlike AI_REQUEST_CACHE an entry is never handed + /// to a different user — that is what keeps AWS-side attribution per user. + static ref BEDROCK_ASSUMED_ROLE_CACHE: Cache<(String, String, String), windmill_ai::ai_bedrock::AssumedRoleCredentials> = Cache::new(500); +} + pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) { AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id); + // Dropped alongside the credentials that named the role, so that changing a + // workspace's AI settings does not leave a session assumed under the old ones + // in play until STS expires it. + #[cfg(feature = "bedrock")] + BEDROCK_ASSUMED_ROLE_CACHE + .retain(|(cached_workspace_id, _, _), _| cached_workspace_id != workspace_id); } /// Shared configuration for every outbound AI HTTP client (the pooled @@ -219,6 +234,12 @@ struct AIStandardResource { deserialize_with = "empty_string_as_none" )] aws_session_token: Option, + #[serde( + alias = "oidcRoleArn", + default, + deserialize_with = "empty_string_as_none" + )] + oidc_role_arn: Option, /// Platform (standard or google_vertex_ai) #[serde(default)] platform: AIPlatform, @@ -301,6 +322,11 @@ async fn resolve_provider_credentials( } else { None }; + let oidc_role_arn = if let Some(role_arn) = resource.oidc_role_arn { + Some(resolve_var(role_arn, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; Ok(ProviderCredentials { provider: provider.clone(), @@ -313,6 +339,7 @@ async fn resolve_provider_credentials( aws_access_key_id, aws_secret_access_key, aws_session_token, + oidc_role_arn, platform: resource.platform, custom_headers: resource.headers, }) @@ -337,6 +364,7 @@ async fn resolve_provider_credentials( aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }) @@ -385,6 +413,109 @@ async fn get_token_using_oauth( Ok(response.access_token) } +/// Exchange the resource's OIDC role ARN for temporary IAM credentials, in place. +/// +/// Only the API holds the OIDC signing key, so the exchange happens here rather +/// than in the Bedrock handler, which then sees ordinary IAM keys. It runs after +/// the workspace credential cache on purpose: the STS session name names the user +/// making the request, and a cache shared by the whole workspace would hand one +/// user's session to another. +#[cfg(feature = "bedrock")] +async fn resolve_bedrock_oidc_credentials( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + credentials: &mut ProviderCredentials, +) -> Result<()> { + use windmill_ai::ai_bedrock::{aws_role_session_name, bedrock_oidc_role_to_assume}; + + // Only the Bedrock resource type carries a role ARN; leaving the field set on + // any other resource inert keeps a hand-edited one from reaching STS. + if credentials.provider != AIProvider::AWSBedrock { + return Ok(()); + } + + let Some(role_arn) = bedrock_oidc_role_to_assume( + credentials.api_key.as_deref(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.oidc_role_arn.as_deref(), + ) else { + return Ok(()); + }; + + // Ahead of the cache: STS credentials are region-independent, so a resource + // with the same role but no region would otherwise reuse an entry and sign a + // request against the empty region the guard exists to reject. + let region = windmill_ai::ai_bedrock::bedrock_oidc_region(credentials.region.as_deref())?; + + let session_name = aws_role_session_name("windmill-ai-copilot", &authed.username); + // Keyed on the email, the identity the OIDC token is minted for, rather than + // on the session name derived from it: that name is sanitised and length + // capped, so it is a lossy stand-in for the caller. + let cache_key = (w_id.to_string(), role_arn.to_string(), authed.email.clone()); + + let assumed = match BEDROCK_ASSUMED_ROLE_CACHE + .get(&cache_key) + .filter(windmill_ai::ai_bedrock::AssumedRoleCredentials::is_fresh) + { + Some(cached) => cached, + None => { + let assumed = + assume_bedrock_role(db, w_id, authed, role_arn, region, &session_name).await?; + BEDROCK_ASSUMED_ROLE_CACHE.insert(cache_key, assumed.clone()); + assumed + } + }; + + credentials.aws_access_key_id = Some(assumed.access_key_id); + credentials.aws_secret_access_key = Some(assumed.secret_access_key); + credentials.aws_session_token = Some(assumed.session_token); + + Ok(()) +} + +#[cfg(feature = "bedrock")] +async fn assume_bedrock_role( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + role_arn: &str, + region: &str, + session_name: &str, +) -> Result { + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + { + use windmill_common::oidc_oss::{generate_id_token, WorkspaceClaim}; + + let id_token = generate_id_token( + Some(db), + WorkspaceClaim { workspace: w_id.to_string() }, + windmill_ai::ai_bedrock::AWS_OIDC_AUDIENCE, + format!("{}::{}", authed.email, w_id), + Some(authed.email.clone()), + None, + ) + .await?; + + windmill_ai::ai_bedrock::assume_role_with_oidc_token( + role_arn, + region, + id_token.to_string(), + session_name, + ) + .await + } + #[cfg(not(all(feature = "enterprise", feature = "openidconnect", feature = "private")))] + { + let _ = (db, w_id, authed, role_arn, region, session_name); + Err(Error::BadRequest( + "Assuming an AWS role through Windmill's OIDC provider requires an enterprise license" + .to_string(), + )) + } +} + #[derive(Clone, Debug)] pub struct ExpiringProviderCredentials { credentials: ProviderCredentials, @@ -976,6 +1107,7 @@ async fn global_proxy( aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }; @@ -1253,6 +1385,9 @@ async fn proxy( } }; + #[cfg(feature = "bedrock")] + resolve_bedrock_oidc_credentials(&db, &w_id, &authed, &mut credentials).await?; + // Free tier: pin the model and clamp max_tokens server-side before forwarding, // since the request body is otherwise client-controlled. if free_lease.is_some() { @@ -1463,6 +1598,7 @@ mod tests { aws_access_key_id: None, aws_secret_access_key: None, aws_session_token: None, + oidc_role_arn: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } @@ -1504,6 +1640,24 @@ mod tests { ("workspace-b".to_string(), AIProvider::OpenAI), ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); + #[cfg(feature = "bedrock")] + let assumed_role_key = { + let key = ( + "workspace-a".to_string(), + "arn:aws:iam::123456789012:role/bedrock".to_string(), + "someone@windmill.dev".to_string(), + ); + BEDROCK_ASSUMED_ROLE_CACHE.insert( + key.clone(), + windmill_ai::ai_bedrock::AssumedRoleCredentials { + access_key_id: "AKIA".to_string(), + secret_access_key: "secret".to_string(), + session_token: "session".to_string(), + expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(3600), + }, + ); + key + }; invalidate_ai_request_cache_for_workspace("workspace-a"); @@ -1516,6 +1670,8 @@ mod tests { assert!(AI_REQUEST_CACHE .get(&("workspace-b".to_string(), AIProvider::OpenAI)) .is_some()); + #[cfg(feature = "bedrock")] + assert!(BEDROCK_ASSUMED_ROLE_CACHE.get(&assumed_role_key).is_none()); } #[test] diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 54886e2568..ef48927676 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4444,8 +4444,15 @@ async fn execute_component( (JobPayload::Code(raw_code), tag, None) } // inline script: run mode (deployed app) with an entry in `app_script`. - (None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => ( - JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path }, + // The path is derived like the legacy arm's: job identity (e.g. OIDC `sub`) + // reads it, so a caller must not choose it. + (None, Some(RawCode { language, cache_ttl, tag, .. }), Some(id)) => ( + JobPayload::AppScript { + id: AppScriptId(id), + cache_ttl, + language, + path: Some(inline_run_path(path, &component)?), + }, resolved_inline_tag(tag), None, ), @@ -4805,8 +4812,9 @@ async fn upload_s3_file_from_app( if let Some(ref s3_resource_path) = query.s3_resource_path { if matched_input.allow_user_resources { if let Some(authed) = opt_authed { + let viewer = policy_granted_viewer(&authed); let db_with_opt_authed = DbWithOptAuthed::from_authed( - &authed, + &viewer, db.clone(), Some(user_db.clone()), ); @@ -5773,6 +5781,14 @@ async fn exists_app( Ok(Json(exists)) } +/// The viewer as whom a resource an app policy lets the viewer pick (`allow_user_resources`) +/// is resolved. The policy, not the token, grants that resource, and app tokens are minted +/// with a fixed scope set that never names variables, so the references inside it resolve on +/// the viewer's RLS alone. +fn policy_granted_viewer(authed: &ApiAuthed) -> ApiAuthed { + ApiAuthed { scopes: None, ..authed.clone() } +} + async fn build_args( policy: &Policy, PolicyTriggerableInputs { @@ -5799,8 +5815,9 @@ async fn build_args( key.and_then(|x| x.clone().strip_prefix("$res:").map(|x| x.to_string())) { if let Some(authed) = authed { + let viewer = policy_granted_viewer(authed); let db_with_opt_authed = - DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); + DbWithOptAuthed::from_authed(&viewer, db.clone(), Some(user_db.clone())); let res = get_resource_value_interpolated_internal( &db_with_opt_authed, w_id, diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 6255b5eb13..8e9fb4a823 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -66,6 +66,7 @@ use crate::{ args::RawWebhookArgs, db::{ApiAuthed, DB}, users::fetch_api_authed, + utils::check_scopes, }; use axum::{ @@ -319,6 +320,7 @@ async fn get_configs( Extension(user_db): Extension, Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:read:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; let configs = sqlx::query_as!( @@ -557,6 +559,7 @@ async fn set_config( Path(w_id): Path, Json(nc): Json, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:write:{}", nc.path))?; let nc = match nc.trigger_kind { TriggerKind::Postgres => { set_postgres_trigger_config(&w_id, authed.clone(), &db, user_db.clone(), nc).await? @@ -616,6 +619,7 @@ async fn ping_config( StripPath, )>, ) -> Result<()> { + check_scopes(&authed, || format!("capture:write:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; if matches!(trigger_kind, TriggerKind::Postgres) { windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; @@ -667,6 +671,7 @@ async fn list_captures( Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, Query(query): Query, ) -> JsonResult> { + check_scopes(&authed, || format!("capture:read:{}", path.to_path()))?; let mut tx = user_db.begin(&authed).await?; let (per_page, offset) = paginate(Pagination { page: query.page, per_page: query.per_page }); @@ -713,12 +718,44 @@ async fn list_captures( Ok(Json(captures)) } +/// A capture addressed by id carries no path in the route, so its path scope can only be +/// checked against the row. Looked up under the caller's RLS so the check never reveals +/// whether an id the caller cannot see exists, and the refusal omits the path, which the +/// caller named only by id. +async fn check_capture_id_scope( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + authed: &ApiAuthed, + w_id: &str, + id: i64, + action: &str, +) -> Result<()> { + if authed.scopes.is_none() { + return Ok(()); + } + let path = sqlx::query_scalar!( + "SELECT path FROM capture WHERE id = $1 AND workspace_id = $2", + id, + w_id, + ) + .fetch_optional(&mut **tx) + .await?; + if let Some(path) = path { + check_scopes(authed, || format!("capture:{action}:{path}")).map_err(|_| { + windmill_common::error::Error::PermissionDenied(format!( + "This token's capture:{action} scope does not cover capture {id}" + )) + })?; + } + Ok(()) +} + async fn get_capture( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, id)): Path<(String, i64)>, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; + check_capture_id_scope(&mut tx, &authed, &w_id, id, "read").await?; let capture = sqlx::query_as!( Capture, @@ -751,6 +788,7 @@ async fn delete_capture( Path((w_id, id)): Path<(String, i64)>, ) -> Result<()> { let mut tx = user_db.begin(&authed).await?; + check_capture_id_scope(&mut tx, &authed, &w_id, id, "write").await?; // capture RLS only keys on the path segment, so without workspace_id an id from // another workspace whose path collides with the caller's grants would be deleted. sqlx::query!( @@ -781,8 +819,10 @@ async fn move_captures_and_configs( Path((w_id, runnable_kind, old_path)): Path<(String, RunnableKind, StripPath)>, Json(body): Json, ) -> Result<()> { - let mut tx = user_db.begin(&authed).await?; let old_path = old_path.to_path(); + check_scopes(&authed, || format!("capture:write:{}", old_path))?; + check_scopes(&authed, || format!("capture:write:{}", body.new_path))?; + let mut tx = user_db.begin(&authed).await?; sqlx::query!( r#" diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 052c9df4bb..27e34b6ef2 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -116,7 +116,10 @@ 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 +307,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 +839,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, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + 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, @@ -2652,7 +2763,7 @@ async fn send_email_with_instance_smtp( Json(send_email): Json, ) -> error::Result> { use windmill_common::jobs::EMAIL_ERROR_HANDLER_USER_EMAIL; - use windmill_queue::SCHEDULE_ERROR_HANDLER_USER_EMAIL; + use windmill_queue::{ERROR_HANDLER_USER_EMAIL, SCHEDULE_ERROR_HANDLER_USER_EMAIL}; if *CLOUD_HOSTED { tracing::warn!( @@ -2661,12 +2772,15 @@ async fn send_email_with_instance_smtp( return Err(anyhow::anyhow!("Feature not supported in cloud hosted windmill").into()); } + // Any code pushed as a workspace or schedule error handler, custom ones included, runs as + // one of these identities: this keeps out ad-hoc job tokens, not who authors handler code. let is_handler_job = authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL + || authed.email == ERROR_HANDLER_USER_EMAIL || authed.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL; if !is_handler_job && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::NotAuthorized( - "Only super admin or whitelisted token can access email workspace error handler feature" + "Only super admin or a workspace/schedule error handler job can send emails with the instance SMTP" .to_string(), )); } @@ -4083,13 +4197,15 @@ async fn get_args( async fn get_started_at_by_ids( Extension(db): Extension, + Path(w_id): Path, Json(mut ids): Json>, ) -> JsonResult>>> { ids.truncate(100); let started_at = sqlx::query!( - "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1)", - ids.as_slice() + "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2", + ids.as_slice(), + &w_id ) .fetch_all(&db) .await?; @@ -5297,7 +5413,7 @@ async fn resume_suspended_job_internal( verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?; // Get flow info - works for step-level, flow-level, and WAC approval - let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?; + let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &w_id, &db).await?; // HMAC secret = full capability. Skip approval_conditions checks: possession of the full // resume URL is the authorization (it is only disclosed to intended approvers, e.g. when a @@ -5529,7 +5645,11 @@ struct FlowInfo { /// Returns (FlowInfo, is_flow_level, is_wac) where: /// - is_flow_level: job_id was a flow job (pre-approval) /// - is_wac: job_id is a WAC workflow suspended for approval (target is itself) -async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowInfo, bool, bool)> { +async fn get_flow_info_for_resume( + job_id: Uuid, + w_id: &str, + db: &DB, +) -> error::Result<(FlowInfo, bool, bool)> { // Single query that determines if job_id is a flow, step, or WAC job, // and fetches the appropriate suspended job info. // For WAC jobs (no parent, not a flow), the job itself is the suspended target. @@ -5538,7 +5658,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI WITH job_info AS ( SELECT id, kind::text AS kind, parent_job FROM v2_job - WHERE id = $1 + WHERE id = $1 AND workspace_id = $2 ) SELECT q.id AS "id!", @@ -5558,10 +5678,15 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI FOR UPDATE OF q "#, job_id, + w_id, ) .fetch_optional(db) .await? - .ok_or_else(|| anyhow::anyhow!("job not found or parent flow not in queue: {}", job_id))?; + .ok_or_else(|| { + Error::NotFound(format!( + "job not found or parent flow not in queue: {job_id}" + )) + })?; let flow_info = FlowInfo { id: result.id, @@ -5865,12 +5990,7 @@ pub async fn create_job_signature( Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::Result { - // The HMAC is treated as full authority by the resume endpoints, so minting - // it requires run scope on the suspended job's flow — not merely any - // jobs:run scope. No-op for unscoped tokens (incl. the in-flow substep token - // used by wmill.get_resume_urls()). - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; let key = get_workspace_key(&w_id, &db).await?; create_signature(key, job_id, resume_id, approver.approver) } @@ -5963,12 +6083,7 @@ pub async fn get_resume_urls( Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::JsonResult { - // These URLs embed a resume signature (full resume capability), so a scoped - // token must hold run scope on the suspended job's flow. No-op for unscoped - // tokens (incl. the in-flow substep token). Trusted internal callers use - // get_resume_urls_internal directly and are unaffected. - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; get_resume_urls_internal( Extension(db), Path((w_id, job_id, resume_id)), @@ -5993,23 +6108,9 @@ pub async fn get_wac_approval_urls( "step_key must be the key of a wait_for_approval step".to_string(), )); } - let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?; - check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?; - - // This handler writes to the job's status row, so the job must actually be in - // the caller's workspace — `v2_job_status` is keyed by job id alone and would - // otherwise take a write aimed at another workspace's job. - let in_workspace = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)", - job_id, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - if !in_workspace { - return Err(Error::NotFound(format!("job {job_id} not found"))); - } + // Also the workspace check for the `v2_job_status` write below, which is keyed by job id + // alone and would otherwise take a write aimed at another workspace's job. + require_resume_mint_authority(&db, &w_id, &authed, job_id).await?; // The approval belongs to the WAC parent, but WM_JOB_ID is the child job when // this is called from inside a task() rather than a step(). Resolve up so the @@ -6209,12 +6310,34 @@ pub async fn get_resume_urls_internal( Ok(Json(res)) } -/// Resolve the runnable path of the flow a (possibly step) job belongs to, used +/// A resume signature is full approval authority: `jobs_u/resume|cancel` skip the step's +/// approval_conditions and accept any resume_id. So only a workspace admin, or a job token +/// whose run lineage claims the suspended job ([`unclaimable_run_lineage`]), may mint one, and +/// a scoped token also needs run scope on the flow. The job must be in `w_id`, whose key signs +/// the result. +async fn require_resume_mint_authority( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + job_id: Uuid, +) -> error::Result<()> { + let flow_path = resume_target_flow_path(db, w_id, job_id).await?; + check_scopes(authed, || format!("jobs:run:flows:{}", flow_path))?; + if !unclaimable_run_lineage(db, w_id, vec![job_id], authed) + .await? + .is_empty() + { + return Err(Error::PermissionDenied(format!( + "only job {job_id}'s own run or a workspace admin can sign resume or approval urls for it" + ))); + } + Ok(()) +} + +/// Resolve the runnable path of the flow a (possibly step) job of `w_id` belongs to, used /// to scope-check resume-signature minting against `jobs:run:flows:`. -/// Returns an empty string when the path can't be resolved (e.g. previews or an -/// unknown job); an empty path only matters for path-restricted tokens, which -/// would not be running such a flow. Never hard-fails, so it can't break resume -/// for unscoped tokens (the in-flow `get_resume_urls()` path). +/// Returns an empty string when the path can't be resolved (e.g. previews); an empty +/// path only matters for path-restricted tokens, which would not be running such a flow. async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Result { let job = sqlx::query!( r#"SELECT kind::text as "kind!", parent_job, runnable_path @@ -6223,10 +6346,8 @@ async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Re w_id ) .fetch_optional(db) - .await?; - let Some(job) = job else { - return Ok(String::new()); - }; + .await? + .ok_or_else(|| Error::NotFound(format!("job {job_id} not found")))?; // All flow kinds: the job itself is the flow whose path scopes the resume. if matches!( job.kind.as_str(), @@ -6385,6 +6506,8 @@ struct BatchReRunQueryReturnType { scheduled_for: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] schema: Option, + #[serde(skip)] + created_by: String, } async fn batch_rerun_compute_js_expression( @@ -6434,7 +6557,7 @@ fn batch_rerun_jobs_inner( BatchReRunQueryReturnType, r#"WITH norm AS ( SELECT - j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, + j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by, -- Project effective kind for dispatch: pass script/flow through; -- for singlestepflow, read the wrapped runnable's type from -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow'). @@ -6473,6 +6596,7 @@ fn batch_rerun_jobs_inner( COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS "script_hash!: _", COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS "scheduled_for!: _", n.args AS input, + n.created_by, -- Pinned schema for script/flow; latest-by-path fallback for -- singlestepflow so input_transforms still resolve at rerun time. COALESCE( @@ -6529,6 +6653,10 @@ async fn batch_rerun_handle_job( w_id: &String, body: &BatchReRunJobsBodyArgs, ) -> error::Result { + // The source jobs are selected on the root pool, and a rerun copies their args into a + // job the caller owns, so each one must pass the same read gate as `jobs_u/get`. + require_job_read_access(db, user_db, authed, w_id, &job.id, &job.created_by, None).await?; + let options = if matches!(job.kind, JobKind::Script) { &body.script_options_by_path } else { @@ -6827,7 +6955,8 @@ async fn resolve_nested_restart( parent_step_id: &str, parent_branch_or_iteration_n: Option, nested_path: Vec, - parent_flow_version: Option, + // (flow path of the restarted job, version to restart on) + parent_flow_version: Option<(&str, i64)>, ) -> error::Result<( Option, Option>, @@ -6855,8 +6984,8 @@ async fn resolve_nested_restart( ) })?; - let flow_data = if let Some(version) = parent_flow_version { - cache::flow::fetch_version(db, version).await? + let flow_data = if let Some((flow_path, version)) = parent_flow_version { + windmill_queue::fetch_restart_flow_version(db, workspace_id, flow_path, version).await? } else { cache::job::fetch_flow(db, &row.job_kind, row.runnable_id) .or_else(|_| cache::job::fetch_preview_flow(db, &parent_job_id, row.raw_flow)) @@ -7033,6 +7162,8 @@ pub async fn restart_flow( .script_path .with_context(|| "No flow path set for completed flow job")?; check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let ehm = HashMap::new(); let push_args = completed_job @@ -7050,7 +7181,7 @@ pub async fn restart_flow( &step_id, branch_or_iteration_n, nested_path, - flow_version, + flow_version.map(|v| (flow_path.as_str(), v)), ) .await?; @@ -7231,6 +7362,13 @@ pub async fn run_workflow_as_code( 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"))?; + // The task becomes a child of `job_id`, runs its code and writes into its flow status, so + // only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it. + if authed.job_id != Some(job_id) && !authed.is_admin { + return Err(error::Error::PermissionDenied(format!( + "only job {job_id}'s own WM_TOKEN can run its workflow tasks" + ))); + } if !is_valid_entrypoint_name(&entrypoint) { return Err(error::Error::BadRequest(format!( @@ -7647,6 +7785,8 @@ pub async fn run_wait_result_job_by_path_get( 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, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_authed.as_ref() { @@ -7793,6 +7933,8 @@ pub async fn run_wait_result_script_by_path_internal( 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, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { @@ -7907,6 +8049,8 @@ pub async fn run_wait_result_script_by_hash( 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, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -9926,6 +10070,8 @@ pub async fn run_job_by_hash_inner( let push_args = PushArgs { args: &args.args, extra: args.extra }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let mut run_query = run_query; + drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index d6b29a8501..156c6ca898 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -13,8 +13,11 @@ use crate::embeddings::load_embeddings_db; use crate::oauth2_oss::SlackVerifier; #[cfg(feature = "smtp")] use crate::smtp_server_oss::SmtpServer; +use axum::middleware::from_fn_with_state; #[cfg(feature = "enterprise")] use windmill_api_auth::ee_oss::ExternalJwks; +use windmill_api_auth::gate_operator_writes; +use windmill_common::workspaces::ManageKind; use windmill_store::resources::public_service; #[cfg(feature = "mcp")] @@ -534,10 +537,15 @@ 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(); + let triggers_service = triggers::generate_trigger_routers().layer(from_fn_with_state( + ManageKind::Triggers, + gate_operator_writes, + )); if !*CLOUD_HOSTED && server_mode && !mcp_mode { start_all_listeners(db.clone(), &killpill_rx); @@ -651,7 +659,15 @@ pub async fn run_server( ) .nest("/assets", windmill_api_assets::workspaced_service()) .nest("/audit", audit::workspaced_service()) - .nest("/capture", capture::workspaced_service()) + // Capture configures a trigger without creating one. Its unauthed + // ingestion routes are a separate service and stay open. + .nest( + "/capture", + capture::workspaced_service().layer(from_fn_with_state( + ManageKind::Triggers, + gate_operator_writes, + )), + ) .nest( "/concurrency_groups", concurrency_groups::workspaced_service(), @@ -696,9 +712,18 @@ pub async fn run_server( .nest("/native_triggers", { #[cfg(feature = "native_trigger")] { - native_triggers::handler::generate_native_trigger_routers().merge( - native_triggers::workspace_integrations::workspaced_service(), - ) + // Only the trigger routes take the gate: the integrations beside + // them connect the workspace's GitHub/Google account, which is a + // settings concern and admin-only already. + native_triggers::handler::generate_native_trigger_routers() + .layer(from_fn_with_state( + ManageKind::Triggers, + gate_operator_writes, + )) + .merge( + native_triggers::workspace_integrations::workspaced_service( + ), + ) } #[cfg(not(feature = "native_trigger"))] { @@ -744,7 +769,13 @@ pub async fn run_server( resources::workspaced_service().layer(cors.clone()), ) .nest("/shared_ui", workspace_shared_ui::workspaced_service()) - .nest("/schedules", windmill_api_schedule::workspaced_service()) + .nest( + "/schedules", + windmill_api_schedule::workspaced_service().layer(from_fn_with_state( + ManageKind::Schedules, + gate_operator_writes, + )), + ) .nest("/scripts", scripts::workspaced_service()) .nest("/trash", trash::workspaced_service()) .nest( diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index ffefaba2dd..1962710578 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -699,6 +699,10 @@ pub fn all_tools() -> Vec { "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-' (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-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 86fbec1806..472c7bbec9 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend { }) } + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { + 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 { all_tools() } diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 271e9497fe..fe3dd75ecd 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -16,23 +16,55 @@ use axum::{ routing::get, Json, Router, }; +use quick_cache::{sync::Cache, Weighter}; use serde::{Deserialize, Serialize}; -use windmill_common::error::JsonResult; +use windmill_common::{db::UserDB, error::JsonResult}; +use windmill_store::var_resource_cache::auth_identity; -use crate::db::{ApiAuthed, DB}; +use crate::db::ApiAuthed; // Per-table row cap is inlined into the SQL as `LIMIT 5000`. // With 6 tables, the absolute ceiling is ~30k paths pre-dedup. /// Final cap applied after dedup/sort. const MAX_PATHS: usize = 20_000; -/// TTL for the per-workspace path list cache. +/// TTL for the path list cache. const CACHE_TTL: Duration = Duration::from_secs(60); -/// Workspace-wide path list cache keyed by workspace_id only. -/// One entry per workspace shared across all users — autocomplete is a -/// navigation hint, not an access gate. Saves memory and warms faster. -static PATHS_CACHE: LazyLock>, Instant)>> = - LazyLock::new(|| quick_cache::sync::Cache::new(500)); +/// Total paths held across all cache entries. +const CACHE_MAX_PATHS: u64 = 2_000_000; + +type CacheKey = (String, String); +type CacheValue = (Arc>, Instant); + +#[derive(Clone)] +struct PathCountWeighter; + +impl Weighter for PathCountWeighter { + fn weight(&self, _key: &CacheKey, (paths, _): &CacheValue) -> u64 { + (paths.len() as u64).max(1) + } +} + +/// Keyed by (workspace_id, [`auth_identity`]): the list is read under the caller's RLS, +/// so an entry must only ever be served back to the same authorization context. +/// Weighted by path count because the key space grows with callers, not workspaces. +/// Single-sharded: quick_cache splits the weight budget across shards and refuses an +/// entry heavier than one shard's share, which would drop the largest workspaces. +static PATHS_CACHE: LazyLock> = + LazyLock::new(|| { + let options = quick_cache::OptionsBuilder::new() + .shards(1) + .estimated_items_capacity(1000) + .weight_capacity(CACHE_MAX_PATHS) + .build() + .expect("every cache option is set"); + Cache::with_options( + options, + PathCountWeighter, + Default::default(), + Default::default(), + ) + }); pub fn workspaced_service() -> Router { Router::new().route("/list_paths", get(list_paths)) @@ -53,20 +85,22 @@ struct ListPathsQuery { } async fn list_paths( - _authed: ApiAuthed, - Extension(db): Extension, + authed: ApiAuthed, + Extension(user_db): Extension, Path(w_id): Path, Query(ListPathsQuery { force }): Query, ) -> JsonResult { + let cache_key = (w_id, auth_identity(&authed)); if !force { - if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) { + if let Some((cached, cached_at)) = PATHS_CACHE.get(&cache_key) { if cached_at.elapsed() < CACHE_TTL { return Ok(Json(ListPathsResponse { paths: cached })); } - PATHS_CACHE.remove(&w_id); + PATHS_CACHE.remove(&cache_key); } } + let mut tx = user_db.begin(&authed).await?; let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( @@ -83,16 +117,17 @@ async fn list_paths( (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000) ) t "#, - &w_id, + &cache_key.0, ) - .fetch_all(&db) + .fetch_all(&mut *tx) .await?; + tx.commit().await?; paths.sort_unstable(); paths.truncate(MAX_PATHS); let paths = Arc::new(paths); - PATHS_CACHE.insert(w_id, (paths.clone(), Instant::now())); + PATHS_CACHE.insert(cache_key, (paths.clone(), Instant::now())); Ok(Json(ListPathsResponse { paths })) } diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 387ba56589..3d85904e50 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -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?; diff --git a/backend/windmill-api/src/workspace_dependencies.rs b/backend/windmill-api/src/workspace_dependencies.rs index 194cc1f0d5..170006f44a 100644 --- a/backend/windmill-api/src/workspace_dependencies.rs +++ b/backend/windmill-api/src/workspace_dependencies.rs @@ -34,13 +34,20 @@ async fn create( authed: ApiAuthed, // Extension(user_db): Extension, Extension(db): Extension, + Path(w_id): Path, Json(nwd): Json, ) -> error::Result<(StatusCode, String)> { - tracing::info!(workspace_id = %nwd.workspace_id, name = ?nwd.name, language = ?nwd.language, "create workspace dependencies"); + tracing::info!(workspace_id = %w_id, name = ?nwd.name, language = ?nwd.language, "create workspace dependencies"); require_admin(authed.is_admin, &authed.username)?; + // `require_admin` vouches for the path workspace only; the body must not target another. + if nwd.workspace_id != w_id { + return Err(error::Error::BadRequest(format!( + "workspace_id `{}` in the request body does not match the workspace `{}` in the path", + nwd.workspace_id, w_id + ))); + } let dep_path = WorkspaceDependencies::to_path(&nwd.name, nwd.language)?; - let w_id = nwd.workspace_id.clone(); let email = authed.email.clone(); let username = authed.username.clone(); diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6b9aae519d..7306c5c59a 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -158,6 +158,7 @@ pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_ pub const CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING: &str = "critical_alerts_on_token_expiry"; pub const CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING: &str = "critical_alert_mute_zombie_job_restart"; +pub const CRITICAL_ALERT_MUTE_STRANDED_JOBS_SETTING: &str = "critical_alert_mute_stranded_jobs"; pub const DEV_INSTANCE_SETTING: &str = "dev_instance"; pub const JWT_SECRET_SETTING: &str = "jwt_secret"; pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; @@ -281,6 +282,51 @@ pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> Ok(()) } +/// Instance-wide accent color (`#rrggbb`) that recolors the UI's accent tokens and +/// tints the sidebar, so each environment of a deployment is recognizable at a glance. +/// Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]. +pub const ACCENT_COLOR_SETTING: &str = "accent_color"; + +/// Validate an [`ACCENT_COLOR_SETTING`] value. +/// +/// Only `#rrggbb` is accepted: the value is interpolated into a stylesheet every user +/// loads, so anything looser is CSS injection into every session of the instance. +pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + let hex = s.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("must be a hex color of the form #rrggbb".to_string()); + } + Ok(()) +} + +/// The settings every signed-in browser reads on each full page load. +#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] +pub struct InstanceUi { + pub instance_banner: Option, + pub accent_color: Option, +} + +pub async fn get_instance_ui(db: &Pool) -> error::Result { + let rows = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str] + ) + .fetch_all(db) + .await?; + let mut ui = InstanceUi::default(); + for row in rows { + match row.name.as_str() { + INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), + ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), + _ => {} + } + } + Ok(ui) +} + /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// /// The receiver path is appended to it verbatim, so anything that doesn't @@ -354,6 +400,10 @@ pub const CONCURRENCY_KEY_MAX_QUEUED_SETTING: &str = "concurrency_key_max_queued // disables the cap. See `windmill-queue/src/jobs.rs`, `check_workspace_queue_cap`. pub const WORKSPACE_MAX_QUEUED_JOBS_SETTING: &str = "workspace_max_queued_jobs"; +// Days after which a pending top-level job whose tag no worker has served in that time is +// canceled. Unset or `0` only alerts. Read by the server monitor on each pass. +pub const CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING: &str = "cancel_stranded_jobs_after_days"; + /// Global settings an agent worker (a remote worker connected over HTTP instead /// of to the database) must NEVER read through /// `GET /api/agent_workers/get_global_setting/{key}`. Every other key is served. @@ -939,6 +989,26 @@ mod tests { } } + #[test] + fn accent_color_accepts_only_hex_rgb() { + for ok in ["#1f9d55", "#ABCDEF"] { + assert!( + validate_accent_color(&serde_json::json!(ok)).is_ok(), + "{ok}" + ); + } + for bad in [ + serde_json::json!("1f9d55"), + serde_json::json!("#fff"), + serde_json::json!("red"), + serde_json::json!("#000000;} body{display:none"), + serde_json::json!("#12345g"), + serde_json::json!(123), + ] { + assert!(validate_accent_color(&bad).is_err(), "{bad}"); + } + } + #[test] fn instance_banner_rejects_unsafe_and_malformed_values() { // The link becomes the href of an anchor shown to every user of the instance, diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index f4884d736e..264916d522 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -232,6 +232,8 @@ pub struct GlobalSettings { pub request_size_limit_mb: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timeout_wait_result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cancel_stranded_jobs_after_days: Option, // Boolean settings #[serde(skip_serializing_if = "Option::is_none")] @@ -249,6 +251,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub critical_alert_mute_ui: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub critical_alert_mute_stranded_jobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub monitor_logs_on_s3: Option, #[serde(skip_serializing_if = "Option::is_none")] pub app_workspaced_route: Option, @@ -473,6 +477,8 @@ pub struct SmtpSettings { pub smtp_tls_implicit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub smtp_disable_tls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smtp_clicktracking_off: Option, } // --------------------------------------------------------------------------- @@ -1354,6 +1360,14 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + let accent_key = crate::global_settings::ACCENT_COLOR_SETTING; + match desired.get(accent_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(color) => crate::global_settings::validate_accent_color(color) + .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. @@ -2270,6 +2284,20 @@ mod tests { assert_eq!(original.custom_tags, reconstructed.custom_tags); } + #[test] + fn smtp_settings_keep_clicktracking_off() { + let smtp = serde_json::json!({ + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_tls_implicit": false, + "smtp_disable_tls": false, + "smtp_clicktracking_off": true, + }); + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({ "smtp_settings": smtp })).unwrap(); + assert_eq!(settings.to_settings_map()["smtp_settings"], smtp); + } + // ----------------------------------------------------------------------- // Serialization edge cases // ----------------------------------------------------------------------- diff --git a/backend/windmill-common/src/job_provenance.rs b/backend/windmill-common/src/job_provenance.rs new file mode 100644 index 0000000000..1e4130de8c --- /dev/null +++ b/backend/windmill-common/src/job_provenance.rs @@ -0,0 +1,104 @@ +use uuid::Uuid; + +use crate::{db::DB, error::Result, jobs::JobKind}; + +/// Where a job's code comes from, as far as its chain of parents can prove it. +pub struct JobProvenance { + /// True only when the job and every ancestor run code at a path taken from a + /// deployed version, never from the request that created the job. + pub deployed: bool, + pub parent_path: Option, + pub root_id: Uuid, + pub root_path: Option, + pub root_kind: JobKind, + pub root_trigger_kind: Option, +} + +struct LineageJob { + id: Uuid, + parent_job: Option, + kind: JobKind, + runnable_path: Option, + trigger_kind: Option, + origin_verified: bool, +} + +/// Reads any job of `w_id` regardless of the caller: authorize access to `job_id` first. +pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result> { + let lineage = sqlx::query_as!( + LineageJob, + r#"WITH RECURSIVE lineage AS ( + SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger, + 0 AS depth + FROM v2_job WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind, + p.trigger, l.depth + 1 + FROM v2_job p JOIN lineage l ON p.id = l.parent_job + WHERE p.workspace_id = $2 AND l.depth < 100 + ) + SELECT id AS "id!", parent_job, kind AS "kind!: JobKind", runnable_path, + trigger_kind::text AS trigger_kind, + -- A restart takes its flow version from the request, so a trusted flow path + -- can carry another flow's code: the version must belong to that path. + CASE kind + WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv + WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2) + WHEN 'script' THEN EXISTS (SELECT 1 FROM script s + WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2) + -- An app editor preview also runs an app script, at an app path it does not + -- have to own; only deployed-app runs are stamped with their app. + WHEN 'appscript' THEN COALESCE(trigger_kind = 'app' + AND starts_with(runnable_path, trigger || '/'), false) + ELSE true + END AS "origin_verified!" + FROM lineage ORDER BY depth"#, + job_id, + w_id + ) + .fetch_all(db) + .await?; + + let Some(root) = lineage.last() else { + return Ok(None); + }; + // An ancestor missing from v2_job (other workspace, depth cap) leaves the chain unproven. + let deployed = root.parent_job.is_none() + && lineage + .iter() + .all(|j| runs_stored_code(j.kind) && j.origin_verified); + Ok(Some(JobProvenance { + deployed, + parent_path: lineage.get(1).and_then(|j| j.runnable_path.clone()), + root_id: root.id, + root_path: root.runnable_path.clone(), + root_kind: root.kind, + root_trigger_kind: root.trigger_kind.clone(), + })) +} + +/// Whether the job's code and its path are both fixed by what was deployed (or, for a +/// hub script, published), rather than supplied by the request that created the job. +fn runs_stored_code(kind: JobKind) -> bool { + match kind { + JobKind::Script + | JobKind::Script_Hub + | JobKind::Flow + | JobKind::SingleStepFlow + | JobKind::FlowScript + | JobKind::FlowNode + | JobKind::AIAgent + | JobKind::AppScript => true, + JobKind::Preview + | JobKind::FlowPreview + | JobKind::Dependencies + | JobKind::FlowDependencies + | JobKind::AppDependencies + | JobKind::Identity + | JobKind::Noop + | JobKind::DeploymentCallback + | JobKind::UnassignedScript + | JobKind::UnassignedFlow + | JobKind::UnassignedSinglestepFlow => false, + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 3e74d8e17f..47a7ea5b2a 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -86,6 +86,7 @@ pub mod workspace_dependencies; #[cfg(feature = "private")] pub mod git_sync_ee; pub mod git_sync_oss; +pub mod job_provenance; pub mod jobs; pub mod jwt; pub mod login_rate_limit; @@ -118,6 +119,7 @@ pub mod queue; pub mod queue_metrics; pub mod result_stream; pub mod runnable_settings; +pub mod runnables; pub mod schedule; pub mod schema; pub mod scripts; @@ -856,6 +858,7 @@ ta9ELulniZau8zUAtwqwecxodzl+KO8NYj0a9PGgAM64dMqkRtRA8P4UP350Nag3\n\ accept_invalid_certs: None, use_iam_auth: None, region: None, + options: None, } } @@ -943,6 +946,26 @@ ta9ELulniZau8zUAtwqwecxodzl+KO8NYj0a9PGgAM64dMqkRtRA8P4UP350Nag3\n\ assert!(pg(None, None).to_uri().contains("sslmode=prefer")); } + #[test] + fn options_survive_to_uri() { + let mut db = pg(Some("require"), None); + db.options = Some("endpoint=ep-x -c search_path=a&b".to_string()); + let uri = db.to_uri(); + let config: tokio_postgres::Config = uri.parse().unwrap(); + assert_eq!(config.get_options(), db.options.as_deref()); + assert_eq!(PgDatabase::parse_uri(&uri).unwrap().options, db.options); + + db.options = Some(String::new()); + assert!(!db.to_uri().contains("options")); + + // Read the way sqlx reads DATABASE_URL: `+` is a space. + let parsed = PgDatabase::parse_uri("postgres://u@h/db?options=-c+search_path%3Dwm"); + assert_eq!( + parsed.unwrap().options.as_deref(), + Some("-c search_path=wm") + ); + } + /// The other paths default a missing login to `postgres`; Entra must not, or the /// server rejects a role the resource never named. #[test] @@ -993,6 +1016,8 @@ pub struct PgDatabase { pub accept_invalid_certs: Option, pub use_iam_auth: Option, pub region: Option, + /// The libpq `options` startup parameter (e.g. `endpoint=` for Neon, `-c search_path=x`). + pub options: Option, } // Wrapper enum to hold either Tls or NoTls connection @@ -1060,8 +1085,12 @@ impl PgDatabase { } else { urlencoding::encode(&self.host).into_owned() }; + let options = match self.non_empty_options() { + Some(o) => format!("&options={}", urlencoding::encode(o)), + None => String::new(), + }; format!( - "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}", + "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}{options}", user = urlencoding::encode(self.login_name()), password = urlencoding::encode(&self.password.as_deref().unwrap_or("")), host = host, @@ -1071,6 +1100,10 @@ impl PgDatabase { ) } + pub fn non_empty_options(&self) -> Option<&str> { + self.options.as_deref().filter(|o| !o.is_empty()) + } + pub async fn connect( &self, main_db: Option<&DB>, @@ -1339,6 +1372,9 @@ impl PgDatabase { .password(token) .dbname(&self.dbname) .ssl_mode(tokio_postgres::config::SslMode::Require); + if let Some(options) = self.non_empty_options() { + config.options(options); + } let (client, connection) = tokio::time::timeout( std::time::Duration::from_secs(20), @@ -1371,9 +1407,14 @@ impl PgDatabase { let port = parsed_url.port(); let dbname = parsed_url.path().trim_start_matches('/').to_string(); let mut sslmode = None; + let mut options = None; + // Form decoding (`+` is a space) on purpose: this parses DATABASE_URL, and the instance's + // own sqlx pool reads it the same way, so connections derived from it must agree. for query in parsed_url.query_pairs() { - if query.0 == "sslmode" { - sslmode = Some(query.1.to_string()); + match query.0.as_ref() { + "sslmode" => sslmode = Some(query.1.to_string()), + "options" => options = Some(query.1.to_string()), + _ => {} } } @@ -1392,6 +1433,7 @@ impl PgDatabase { accept_invalid_certs: None, use_iam_auth: None, region: None, + options, }) } } diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 60a09ce9b7..7a1a6948ce 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -64,6 +64,12 @@ pub struct JobClaim { pub fork_parent_workspace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end_user_email: Option, + pub job_kind: String, + pub deployed: bool, + pub root_job_id: String, + pub root_path: Option, + pub root_job_kind: String, + pub root_trigger_kind: Option, } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 546d2d454b..be608ebf34 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -189,6 +189,82 @@ fn duckdb_version_suffix(version: Option) -> String { } } +/// What a preview reads FROM: the table itself, or with joined columns a derived table of its +/// rows beside them. Joining inside a derived table leaves the joined columns as plain columns +/// of the source, so every dialect's select list, quicksearch, ordering and caller filters apply +/// to them as written, unqualified. +fn select_source( + table: &str, + joins: &[JoinedColumn], + db_type: DbType, + version: Option, +) -> String { + // DuckDB takes the time-travel clause after an alias, not before it. + let at = |t: &str, alias: Option<&str>| { + let suffix = if db_type == DbType::Duckdb { + duckdb_version_suffix(version) + } else { + String::new() + }; + let alias = alias.map(|a| format!(" AS {}", a)).unwrap_or_default(); + format!("{}{}{}", quote_table_name(t, db_type), alias, suffix) + }; + if joins.is_empty() { + return at(table, None); + } + // One join per foreign key, however many of its target's columns are shown. + let mut keys: Vec<(&str, &str, &str)> = vec![]; + for j in joins { + let key = ( + j.source_column.as_str(), + j.target_table.as_str(), + j.target_column.as_str(), + ); + if !keys.contains(&key) { + keys.push(key); + } + } + let join_alias = |j: &JoinedColumn| { + let key = ( + j.source_column.as_str(), + j.target_table.as_str(), + j.target_column.as_str(), + ); + format!("wm_j{}", keys.iter().position(|k| *k == key).unwrap_or(0)) + }; + let columns = joins + .iter() + .map(|j| { + format!( + "{}.{} AS {}", + join_alias(j), + qi(&j.column, db_type), + qi(&j.alias, db_type) + ) + }) + .collect::>() + .join(", "); + let join_clauses = keys + .iter() + .enumerate() + .map(|(i, (source, target_table, target_column))| { + format!( + " LEFT JOIN {} ON wm_base.{} = wm_j{}.{}", + at(target_table, Some(&format!("wm_j{}", i))), + qi(source, db_type), + i, + qi(target_column, db_type) + ) + }) + .collect::(); + format!( + "(SELECT wm_base.*, {} FROM {}{}) AS wm_src", + columns, + at(table, Some("wm_base")), + join_clauses + ) +} + // --------------------------------------------------------------------------- // WM_INTERNAL_DB expansion: marker detection and SQL generation // --------------------------------------------------------------------------- @@ -207,6 +283,25 @@ struct SelectPayload { ducklake: Option, /// DuckLake snapshot to time-travel the read to (DuckDB only). version: Option, + #[serde(default)] + joins: Vec, +} + +/// A column of a table the previewed one references by a single-column foreign key, shown +/// beside its own columns. `columnDefs` lists it too, as `alias`, with the target's datatype. +#[derive(Debug, Clone, Deserialize)] +pub struct JoinedColumn { + /// The previewed table's foreign key column. + #[serde(rename = "sourceColumn")] + pub source_column: String, + #[serde(rename = "targetTable")] + pub target_table: String, + /// The column the foreign key references in `target_table`. + #[serde(rename = "targetColumn")] + pub target_column: String, + /// The column of `target_table` to show. + pub column: String, + pub alias: String, } #[derive(Deserialize)] @@ -219,6 +314,8 @@ struct CountPayload { ducklake: Option, /// DuckLake snapshot to time-travel the count to (DuckDB only). version: Option, + #[serde(default)] + joins: Vec, } /// `WM_INTERNAL_DB_DUCKLAKE_SNAPSHOTS` payload — lists the time-travel history @@ -368,8 +465,9 @@ fn expand_select(json_str: &str, db_type: DbType) -> Result { .fix_pg_int_types .map(|v| BreakingFeatures { fix_pg_int_types: v }); - let query = make_select_query( + let query = make_select_query_with_joins( &payload.table, + &payload.joins, &payload.column_defs, payload.where_clause.as_deref(), db_type, @@ -384,9 +482,10 @@ fn expand_count(json_str: &str, db_type: DbType) -> Result { let payload: CountPayload = serde_json::from_str(json_str).map_err(|e| format!("Invalid COUNT payload: {}", e))?; - let query = make_count_query( + let query = make_count_query_with_joins( db_type, &payload.table, + &payload.joins, payload.where_clause.as_deref(), &payload.column_defs, payload.version, @@ -618,6 +717,7 @@ pub fn build_visible_field_list(column_defs: &[ColumnDef], db_type: DbType) -> V fn make_snowflake_select_query( table: &str, + joins: &[JoinedColumn], column_defs: &[ColumnDef], where_clause: Option<&str>, options: Option<&SelectOptions>, @@ -638,7 +738,7 @@ fn make_snowflake_select_query( query.push_str(&format!( "SELECT {} FROM {}", select_clause, - quote_table_name(table, DbType::Snowflake) + select_source(table, joins, DbType::Snowflake, None) )); // quicksearch condition @@ -728,6 +828,26 @@ pub fn make_select_query( db_type: DbType, options: Option<&SelectOptions>, breaking_features: Option<&BreakingFeatures>, +) -> Result { + make_select_query_with_joins( + table, + &[], + column_defs, + where_clause, + db_type, + options, + breaking_features, + ) +} + +pub fn make_select_query_with_joins( + table: &str, + joins: &[JoinedColumn], + column_defs: &[ColumnDef], + where_clause: Option<&str>, + db_type: DbType, + options: Option<&SelectOptions>, + breaking_features: Option<&BreakingFeatures>, ) -> Result { if table.is_empty() { return Err("Table name is required".to_string()); @@ -788,6 +908,7 @@ pub fn make_select_query( DbType::Snowflake => { return Ok(make_snowflake_select_query( table, + joins, column_defs, where_clause, options, @@ -818,7 +939,7 @@ pub fn make_select_query( query.push_str(&format!( "SELECT {} FROM {}", select_clause, - quote_table_name(table, db_type) + select_source(table, joins, db_type, None) )); query.push_str(&format!( " WHERE {} {}", @@ -884,7 +1005,7 @@ pub fn make_select_query( .map(|c| format!("{}::text", c)) .collect::>() .join(", "), - quote_table_name(table, db_type) + select_source(table, joins, db_type, None) )); query.push_str(&format!( " WHERE {} {}\n", @@ -943,7 +1064,7 @@ pub fn make_select_query( query.push_str(&format!( "SELECT {} FROM {}", select_clause, - quote_table_name(table, db_type) + select_source(table, joins, db_type, None) )); query.push_str(&format!( " WHERE {} {}", @@ -1012,7 +1133,7 @@ pub fn make_select_query( query.push_str(&format!( "SELECT {} FROM {}", select_clause, - quote_table_name(table, db_type) + select_source(table, joins, db_type, None) )); query.push_str(&format!( " WHERE {} {}", @@ -1049,10 +1170,9 @@ pub fn make_select_query( ); query.push_str(&format!( - "SELECT {} FROM {}{}\n", + "SELECT {} FROM {}\n", filtered_columns.join(", "), - quote_table_name(table, db_type), - duckdb_version_suffix(options.and_then(|o| o.version)) + select_source(table, joins, db_type, options.and_then(|o| o.version)) )); query.push_str(&format!( " WHERE {} {}\n", @@ -1080,6 +1200,19 @@ pub fn make_count_query( // DuckLake time-travel snapshot (DuckDB only); `None` counts the latest. version: Option, ) -> Result { + make_count_query_with_joins(db_type, table, &[], where_clause, column_defs, version) +} + +pub fn make_count_query_with_joins( + db_type: DbType, + table: &str, + joins: &[JoinedColumn], + where_clause: Option<&str>, + column_defs: &[ColumnDef], + // DuckLake time-travel snapshot (DuckDB only); `None` counts the latest. + version: Option, +) -> Result { + let source = select_source(table, joins, db_type, version); let where_prefix = " WHERE "; let and_condition = " AND "; let mut quicksearch_condition = String::new(); @@ -1118,10 +1251,7 @@ pub fn make_count_query( } else { quicksearch_condition.push_str(" (:quicksearch = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}", - quote_table_name(table, db_type) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } DbType::Postgresql => { if !filtered_columns.is_empty() { @@ -1132,10 +1262,7 @@ pub fn make_count_query( } else { quicksearch_condition.push_str("($1 = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}", - quote_table_name(table, db_type) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } DbType::MsSqlServer => { if !filtered_columns.is_empty() { @@ -1146,10 +1273,7 @@ pub fn make_count_query( } else { quicksearch_condition.push_str("(@p1 = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}", - quote_table_name(table, db_type) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } DbType::Snowflake => { if !filtered_columns.is_empty() { @@ -1173,10 +1297,7 @@ pub fn make_count_query( query.push('\n'); quicksearch_condition.push_str("(? = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}", - quote_table_name(table, db_type) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } DbType::Bigquery => { if !filtered_columns.is_empty() { @@ -1205,10 +1326,7 @@ pub fn make_count_query( } else { quicksearch_condition.push_str("(@quicksearch = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}", - quote_table_name(table, db_type) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } DbType::Duckdb => { if !filtered_columns.is_empty() { @@ -1219,11 +1337,7 @@ pub fn make_count_query( } else { quicksearch_condition.push_str(" ($quicksearch = '' OR 1 = 1)"); } - query.push_str(&format!( - "SELECT COUNT(*) as count FROM {}{}", - quote_table_name(table, db_type), - duckdb_version_suffix(version) - )); + query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source)); } } @@ -1288,16 +1402,7 @@ pub fn make_delete_query(table: &str, columns: &[ColumnDef], db_type: DbType) -> let conditions: String = columns .iter() .enumerate() - .map(|(i, c)| { - let qf = qi(&c.field, db_type); - format!( - "(${} IS NULL AND {} IS NULL OR {} = ${})", - i + 1, - qf, - qf, - i + 1, - ) - }) + .map(|(i, c)| pg_row_match(&c.field, &c.datatype, i + 1)) .collect::>() .join("\n AND "); query.push_str(&format!( @@ -1407,13 +1512,41 @@ fn get_user_default_value(column: &ColumnDef) -> Option { None } +/// Whether a PostgreSQL column is JSON. The preview reads and edits every value as text, and +/// the executor binds a string parameter as `text`, which Postgres will not assign to a JSON +/// column implicitly. +fn pg_is_json(datatype: &str) -> bool { + matches!(datatype.trim().to_lowercase().as_str(), "json" | "jsonb") +} + +/// A PostgreSQL parameter as a value for a column of `datatype`: JSON goes through text, so +/// either a string or a JSON value can be bound. +fn pg_value(param: &str, datatype: &str) -> String { + if pg_is_json(datatype) { + format!("{}::text::{}", param, datatype.trim().to_lowercase()) + } else { + param.to_string() + } +} + +/// PostgreSQL predicate matching a row's `field` against parameter `$param`, NULL included. +/// The row's JSON values were read as text, and `json` has no `=`: they compare as text. +fn pg_row_match(field: &str, datatype: &str, param: usize) -> String { + let qf = qi(field, DbType::Postgresql); + if pg_is_json(datatype) { + format!("(${param} IS NULL AND {qf} IS NULL OR {qf}::text = ${param}::text)") + } else { + format!("(${param} IS NULL AND {qf} IS NULL OR {qf} = ${param})") + } +} + fn format_insert_values(columns: &[ColumnDef], db_type: DbType, start_index: usize) -> String { columns .iter() .enumerate() .map(|(i, c)| match db_type { DbType::Mysql => format!(":{}", c.field), - DbType::Postgresql => format!("${}", start_index + i), + DbType::Postgresql => pg_value(&format!("${}", start_index + i), &c.datatype), DbType::MsSqlServer => format!("@p{}", start_index + i), DbType::Snowflake => "?".to_string(), DbType::Bigquery => format!("@{}", c.field), @@ -1588,22 +1721,16 @@ pub fn make_update_query( let conditions: String = columns .iter() .enumerate() - .map(|(i, c)| { - let qf = qi(&c.field, db_type); - format!( - "(${} IS NULL AND {} IS NULL OR {} = ${})", - i + 2, - qf, - qf, - i + 2, - ) - }) + .map(|(i, c)| pg_row_match(&c.field, &c.datatype, i + 2)) .collect::>() .join("\n AND "); query.push_str(&format!( - "\nUPDATE {} SET {} = $1 \nWHERE {}\tRETURNING 1", - qt, qcol, conditions + "\nUPDATE {} SET {} = {} \nWHERE {}\tRETURNING 1", + qt, + qcol, + pg_value("$1", &column.datatype), + conditions )); } DbType::Mysql => { @@ -2931,6 +3058,133 @@ mod tests { SimpleColumn { field: field.to_string(), datatype: datatype.to_string() } } + fn joined(source: &str, target: &str, column: &str) -> JoinedColumn { + JoinedColumn { + source_column: source.to_string(), + target_table: target.to_string(), + target_column: "id".to_string(), + column: column.to_string(), + alias: format!("{}.{}", source, column), + } + } + + #[test] + fn test_select_joins_read_from_a_derived_source() { + let cols = vec![ + col("id", "int4"), + col("user_id", "int4"), + col("user_id.name", "text"), + ]; + // Two columns through one foreign key share its join. + let joins = vec![ + joined("user_id", "public.users", "name"), + joined("user_id", "public.users", "email"), + ]; + let result = make_select_query_with_joins( + "public.orders", + &joins, + &cols, + None, + DbType::Postgresql, + None, + None, + ) + .unwrap(); + assert!(result.contains( + r#"FROM (SELECT wm_base.*, wm_j0."name" AS "user_id.name", wm_j0."email" AS "user_id.email" FROM "public"."orders" AS wm_base LEFT JOIN "public"."users" AS wm_j0 ON wm_base."user_id" = wm_j0."id") AS wm_src"# + )); + // The joined column is an ordinary column of the source: selected and searched as such. + assert!(result.contains(r#""user_id.name"::text"#)); + + let count = make_count_query_with_joins( + DbType::Postgresql, + "public.orders", + &joins, + None, + &cols, + None, + ) + .unwrap(); + assert!(count.contains("SELECT COUNT(*) as count FROM (SELECT wm_base.*")); + assert!(count.contains(") AS wm_src WHERE ($1 = ''")); + } + + #[test] + fn test_duckdb_joins_alias_before_time_travel() { + let cols = vec![col("id", "INTEGER"), col("user_id.name", "VARCHAR")]; + let opts = SelectOptions { limit: None, offset: None, version: Some(3) }; + let result = make_select_query_with_joins( + "main.orders", + &[joined("user_id", "main.users", "name")], + &cols, + None, + DbType::Duckdb, + Some(&opts), + None, + ) + .unwrap(); + assert!(result.contains(r#"FROM "main"."orders" AS wm_base AT (VERSION => 3) LEFT JOIN "main"."users" AS wm_j0 AT (VERSION => 3) ON"#)); + } + + #[test] + fn test_joins_read_from_a_derived_source_in_every_dialect() { + let cols = vec![col("id", "int"), col("user_id.name", "text")]; + let joins = vec![joined("user_id", "s.users", "name")]; + for (db_type, expected) in [ + ( + DbType::Mysql, + "FROM (SELECT wm_base.*, wm_j0.`name` AS `user_id.name` FROM `s`.`orders` AS wm_base LEFT JOIN `s`.`users` AS wm_j0 ON wm_base.`user_id` = wm_j0.`id`) AS wm_src", + ), + ( + DbType::MsSqlServer, + "FROM (SELECT wm_base.*, wm_j0.[name] AS [user_id.name] FROM [s].[orders] AS wm_base LEFT JOIN [s].[users] AS wm_j0 ON wm_base.[user_id] = wm_j0.[id]) AS wm_src", + ), + ( + DbType::Snowflake, + r#"FROM (SELECT wm_base.*, wm_j0."name" AS "user_id.name" FROM "s"."orders" AS wm_base LEFT JOIN "s"."users" AS wm_j0 ON wm_base."user_id" = wm_j0."id") AS wm_src"#, + ), + ( + DbType::Bigquery, + "FROM (SELECT wm_base.*, wm_j0.`name` AS `user_id.name` FROM `s`.`orders` AS wm_base LEFT JOIN `s`.`users` AS wm_j0 ON wm_base.`user_id` = wm_j0.`id`) AS wm_src", + ), + ] { + let select = + make_select_query_with_joins("s.orders", &joins, &cols, None, db_type, None, None) + .unwrap(); + assert!(select.contains(expected), "{:?} select: {}", db_type, select); + let count = + make_count_query_with_joins(db_type, "s.orders", &joins, None, &cols, None).unwrap(); + assert!(count.contains(expected), "{:?} count: {}", db_type, count); + } + } + + #[test] + fn test_pg_json_columns_are_written_through_text() { + let update = make_update_query( + "t", + &simple_col("js", "jsonb"), + &[simple_col("js", "jsonb")], + DbType::Postgresql, + ); + assert!(update.contains(r#"SET "js" = $1::text::jsonb"#), "{}", update); + assert!(update.contains(r#""js"::text = $2::text"#), "{}", update); + let insert = + make_insert_query("t", &[col("id", "int4"), col("js", "jsonb")], DbType::Postgresql) + .unwrap(); + assert!(insert.contains("VALUES ($1, $2::text::jsonb"), "{}", insert); + let delete = make_delete_query("t", &[col("js", "json")], DbType::Postgresql); + assert!(delete.contains(r#""js"::text = $1::text"#), "{}", delete); + } + + #[test] + fn test_select_without_joins_is_unchanged() { + let cols = vec![col("id", "int4")]; + assert_eq!( + make_select_query_with_joins("t", &[], &cols, None, DbType::Mysql, None, None).unwrap(), + make_select_query("t", &cols, None, DbType::Mysql, None, None).unwrap() + ); + } + // ----------------------------------------------------------------------- // build_parameters // ----------------------------------------------------------------------- diff --git a/backend/windmill-common/src/runnables.rs b/backend/windmill-common/src/runnables.rs new file mode 100644 index 0000000000..579e5e248e --- /dev/null +++ b/backend/windmill-common/src/runnables.rs @@ -0,0 +1,22 @@ +//! Queries spanning both scripts and flows. + +use crate::{error::Error, DB}; + +/// An opaque value that changes whenever a workspace's live scripts or flows are added, +/// removed, moved or redeployed: a redeploy is a new script hash or a new last flow +/// version, so schema and description changes register too. Not filtered by caller, +/// so it reveals nothing beyond whether it changed. +pub async fn runnable_list_fingerprint(db: &DB, workspace_id: &str) -> Result { + Ok(sqlx::query_scalar::<_, String>( + "SELECT COALESCE(md5(string_agg(k, ',' ORDER BY k)), '') FROM ( + SELECT 's' || path || ':' || hash FROM script + WHERE workspace_id = $1 AND archived = false + UNION ALL + SELECT 'f' || path || ':' || COALESCE(versions[array_upper(versions, 1)], 0) FROM flow + WHERE workspace_id = $1 AND archived = false + ) t(k)", + ) + .bind(workspace_id) + .fetch_one(db) + .await?) +} diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 628ae2a709..9726065c6f 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -24,6 +24,22 @@ use regex::Regex; use crate::utils::StripPath; +/// The tag a script's dependency job runs on, given the script's own tag; `None` leaves it to +/// `push`'s default (the language tag). Every relock of a script must use this, or a relock +/// lands on a worker pool other than the one its deploy locked on and resolves packages +/// differently. +/// Dedicated workers are handled by `push`, which gives `dedicated_worker` precedence. +pub fn dependency_job_tag(tag: Option, language: &ScriptLang) -> Option { + if tag.as_ref().is_some_and(|x| x.contains("$args[")) { + None + } else if *language == ScriptLang::Bunnative { + // the bundle must be built on a worker with the bun tag, whatever the script's tag + None + } else { + tag + } +} + pub fn extract_workspace_dependencies_annotated_refs( lang: &ScriptLang, code: &str, diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 0cdf7ffa4f..583b1e81ab 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -396,7 +396,10 @@ pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String { } } -fn is_private_ip(ip: &IpAddr) -> bool { +/// Whether `ip` is loopback, private, link-local, or otherwise not a public +/// internet destination. The one predicate every server-side outbound-URL guard +/// (SSRF checks, git remotes, object storage tests) judges addresses with. +pub fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), IpAddr::V6(ipv6) => is_private_ipv6(ipv6), @@ -408,6 +411,7 @@ fn is_private_ipv4(ip: &Ipv4Addr) -> bool { || ip.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 || ip.is_link_local() // 169.254.0.0/16 (AWS IMDS lives here) || ip.is_broadcast() // 255.255.255.255 + || ip.is_multicast() // 224.0.0.0/4 // RFC 1122 "this network": the whole /8, not just the unspecified // address `is_unspecified()` matches — stacks that map 0.x.y.z onto the // local host make `0.0.0.1` a bypass. @@ -418,17 +422,60 @@ fn is_private_ipv4(ip: &Ipv4Addr) -> bool { } fn is_private_ipv6(ip: &Ipv6Addr) -> bool { + let seg = ip.segments(); ip.is_loopback() // ::1 || ip.is_unspecified() // :: + || ip.is_multicast() // ff00::/8 // Unique local addresses (fc00::/7) - || (ip.segments()[0] & 0xfe00) == 0xfc00 - // Link-local (fe80::/10) - || (ip.segments()[0] & 0xffc0) == 0xfe80 - // IPv4-mapped addresses — check the embedded IPv4 - || match ip.to_ipv4_mapped() { - Some(ipv4) => is_private_ipv4(&ipv4), - None => false, - } + || (seg[0] & 0xfe00) == 0xfc00 + // Link-local (fe80::/10) and deprecated site-local (fec0::/10) + || (seg[0] & 0xffc0) == 0xfe80 + || (seg[0] & 0xffc0) == 0xfec0 + // NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215): a site's own + // translator, whose IPv4 placement depends on the prefix length it uses. + || (seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0x0001) + || embedded_ipv4(ip).is_some_and(|v4| is_private_ipv4(&v4)) +} + +/// The IPv4 address an IPv6 address carries through a transition mechanism, +/// which a dual-stack host or a translator on the path will deliver to. Guards +/// must judge that address too, else e.g. `64:ff9b::a9fe:a9fe` reaches +/// 169.254.169.254 through a NAT64 gateway. +fn embedded_ipv4(ip: &Ipv6Addr) -> Option { + let seg = ip.segments(); + let low32 = || { + Ipv4Addr::new( + (seg[6] >> 8) as u8, + seg[6] as u8, + (seg[7] >> 8) as u8, + seg[7] as u8, + ) + }; + match seg { + // IPv4-mapped ::ffff:0:0/96 + [0, 0, 0, 0, 0, 0xffff, _, _] => Some(low32()), + // IPv4-translated ::ffff:0:0:0/96 (SIIT) + [0, 0, 0, 0, 0xffff, 0, _, _] => Some(low32()), + // IPv4-compatible ::/96 (deprecated, still routed by some stacks) + [0, 0, 0, 0, 0, 0, _, _] => Some(low32()), + // NAT64 well-known prefix 64:ff9b::/96 + [0x0064, 0xff9b, 0, 0, 0, 0, _, _] => Some(low32()), + // 6to4 2002::/16: the site router's IPv4 sits right after the prefix + [0x2002, hi, lo, ..] => Some(Ipv4Addr::new( + (hi >> 8) as u8, + hi as u8, + (lo >> 8) as u8, + lo as u8, + )), + // Teredo 2001::/32: the client's IPv4 is stored bit-inverted + [0x2001, 0, ..] => Some(Ipv4Addr::new( + !(seg[6] >> 8) as u8, + !seg[6] as u8, + !(seg[7] >> 8) as u8, + !seg[7] as u8, + )), + _ => None, + } } #[cfg(test)] @@ -486,31 +533,50 @@ mod tests { } #[test] - fn test_private_ipv4() { - assert!(is_private_ipv4(&"127.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"10.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"172.16.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"192.168.1.1".parse().unwrap())); - assert!(is_private_ipv4(&"169.254.169.254".parse().unwrap())); - assert!(is_private_ipv4(&"0.0.0.0".parse().unwrap())); - // The whole 0.0.0.0/8 block, not just the unspecified address. - assert!(is_private_ipv4(&"0.0.0.1".parse().unwrap())); - assert!(is_private_ipv4(&"0.255.255.255".parse().unwrap())); - assert!(is_private_ipv4(&"100.64.0.1".parse().unwrap())); - assert!(!is_private_ipv4(&"8.8.8.8".parse().unwrap())); - assert!(!is_private_ipv4(&"1.1.1.1".parse().unwrap())); - } - - #[test] - fn test_private_ipv6() { - assert!(is_private_ipv6(&"::1".parse().unwrap())); - assert!(is_private_ipv6(&"::".parse().unwrap())); - assert!(is_private_ipv6(&"fc00::1".parse().unwrap())); - assert!(is_private_ipv6(&"fd00::1".parse().unwrap())); - assert!(is_private_ipv6(&"fe80::1".parse().unwrap())); - // IPv4-mapped loopback - assert!(is_private_ipv6(&"::ffff:127.0.0.1".parse().unwrap())); - assert!(!is_private_ipv6(&"2001:4860:4860::8888".parse().unwrap())); + fn test_private_ip() { + for s in [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + // The whole 0.0.0.0/8 block, not just the unspecified address. + "0.0.0.0", + "0.0.0.1", + "0.255.255.255", + "::1", + "::", + "fc00::1", + "fd00::1", + "fe80::1", + // IPv4 carried inside IPv6 by a transition mechanism. + "::ffff:127.0.0.1", // IPv4-mapped + "::169.254.169.254", // IPv4-compatible + "::ffff:0:a9fe:a9fe", // IPv4-translated (SIIT) + "64:ff9b::a9fe:a9fe", // NAT64 well-known prefix + "64:ff9b:1::a9fe:a9fe", // NAT64 local-use prefix + "64:ff9b:1:ffff::1", // any address in the local-use prefix + "2002:a9fe:a9fe::1", // 6to4 + "2002:7f00:1::", // 6to4 + "2001:0:1:2:0:0:80ff:fffe", // Teredo, client 127.0.0.1 bit-inverted + "2001:0:1:2::5601:5601", // Teredo, client 169.254.169.254 bit-inverted + ] { + let ip: IpAddr = s.parse().unwrap(); + assert!(is_private_ip(&ip), "{s} should be private"); + } + for s in [ + "8.8.8.8", + "1.1.1.1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "64:ff9b::808:808", // NAT64 of 8.8.8.8 + "2002:808:808::1", // 6to4 of 8.8.8.8 + "2001:0:1:2::f7f7:f7f7", // Teredo, client 8.8.8.8 + ] { + let ip: IpAddr = s.parse().unwrap(); + assert!(!is_private_ip(&ip), "{s} should be public"); + } } #[tokio::test] diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 6f82e3a261..b285332b7d 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1186,6 +1186,138 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) { PROTECTION_RULES_CACHE.remove(workspace_id); } +// Operator rights cache + +lazy_static::lazy_static! { + static ref OPERATOR_RIGHTS_CACHE: Cache = Cache::new(1000); +} + +/// Writes an operator may perform unless the workspace withdraws them. Unlike the visibility +/// flags beside them in `operator_settings`, these are enforced; unlike a right a workspace +/// grants, they are held by default and cost no seat. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct OperatorManageRights { + pub schedules: bool, + pub triggers: bool, +} + +/// Granted unless explicitly withdrawn. Every path that materializes these rights without a +/// stored setting - a workspace with no `workspace_settings` row included - has to land here, or +/// an upgrade silently revokes what operators could do the day before. +impl Default for OperatorManageRights { + fn default() -> Self { + Self { schedules: true, triggers: true } + } +} + +/// Which withdrawable capability a gate needs. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ManageKind { + Schedules, + Triggers, +} + +impl ManageKind { + /// Plural noun for the refusal message. + pub fn noun(&self) -> &'static str { + match self { + ManageKind::Schedules => "schedules", + ManageKind::Triggers => "triggers", + } + } + + /// Prefixes the `/acls` audit action (`schedules.grant_acl`). Deliberately its own string + /// rather than `noun()`, which it happens to equal: audit actions are a stable key that rows + /// already on disk were written with, so rewording the message must not rename them. + pub fn audit_prefix(&self) -> &'static str { + match self { + ManageKind::Schedules => "schedules", + ManageKind::Triggers => "triggers", + } + } +} + +impl OperatorManageRights { + pub fn has(&self, kind: ManageKind) -> bool { + match kind { + ManageKind::Schedules => self.schedules, + ManageKind::Triggers => self.triggers, + } + } +} + +/// What operators of this workspace may still write. Per workspace, not per user. +/// +/// Read on every gated write, so it is cached with a 60s TTL. This gates writes, so a withdrawal +/// cannot wait out that TTL on the rest of the fleet: an `AFTER UPDATE OF operator_settings` +/// trigger publishes `notify_operator_settings_change` and every server drops its entry through +/// `process_notify_event`. [`invalidate_operator_rights_cache`] is the local half of that, and +/// what a test flipping the setting directly has to call itself. +/// +/// Call it before opening an RLS transaction: it takes a connection from the root pool, and a +/// second pooled connection held alongside a transaction self-deadlocks on a one-connection pool. +async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result { + let now = chrono::Utc::now().timestamp(); + + if let Some((rights, expiry)) = OPERATOR_RIGHTS_CACHE.get(workspace_id) { + if expiry > now { + return Ok(rights); + } + } + + // Coalesced to true, matching `OperatorManageRights::default`: an absent key means the + // workspace never configured the right, not that it withdrew it. + let row = sqlx::query!( + "SELECT COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\", + COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\" + FROM workspace_settings WHERE workspace_id = $1", + workspace_id + ) + .fetch_optional(db) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to fetch operator settings for {workspace_id}: {e:#}" + )) + })?; + + let rights = row + .map(|r| OperatorManageRights { schedules: r.schedules, triggers: r.triggers }) + .unwrap_or_default(); + + OPERATOR_RIGHTS_CACHE.insert(workspace_id.to_string(), (rights, now + 60)); + + Ok(rights) +} + +/// Invalidate the operator rights cache for a workspace +pub fn invalidate_operator_rights_cache(workspace_id: &str) { + OPERATOR_RIGHTS_CACHE.remove(workspace_id); +} + +/// Gate for a write operators may perform unless the workspace withdrew it. Prefer layering +/// `gate_operator_writes` on a whole router over calling this per handler; see +/// `docs/operator-write-rights.md`. Both `workspace_id` and `is_operator` MUST come from the +/// authenticated request (the route's workspace, `ApiAuthed::is_operator`): passing `false` skips +/// the check, and another workspace's id checks the wrong settings. +pub async fn check_operator_can_manage( + db: &DB, + workspace_id: &str, + is_operator: bool, + kind: ManageKind, +) -> Result<()> { + if is_operator && !operator_manage_rights(db, workspace_id).await?.has(kind) { + // 403, not 401: the caller is authenticated and simply lacks the right. The frontend reads + // an uncaught 401 as a dead session and logs the user out, so `NotAuthorized` here would + // eject an operator from the app instead of telling them why. + return Err(Error::PermissionDenied(format!( + "Operators cannot manage {} in this workspace", + kind.noun() + ))); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleCheckResult { Allowed, diff --git a/backend/windmill-common/tests/operator_settings_notify.rs b/backend/windmill-common/tests/operator_settings_notify.rs new file mode 100644 index 0000000000..08c7ebf11e --- /dev/null +++ b/backend/windmill-common/tests/operator_settings_notify.rs @@ -0,0 +1,48 @@ +/*! + * The integration tests clear the rights cache in-process, so they pass whether or not this + * trigger fires. Only the emitting half is covered here: `process_notify_event` lives in the + * server binary. + */ + +use sqlx::{Pool, Postgres}; + +const WS: &str = "test-workspace"; + +async fn emitted_payloads(db: &Pool) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT payload FROM notify_event WHERE channel = 'notify_operator_settings_change'", + ) + .fetch_all(db) + .await + .expect("read notify events") +} + +async fn set_operator_settings(db: &Pool, settings: &str) { + sqlx::query( + "UPDATE workspace_settings SET operator_settings = $2::jsonb WHERE workspace_id = $1", + ) + .bind(WS) + .bind(settings) + .execute(db) + .await + .expect("update operator settings"); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn withdrawing_a_right_emits_the_cache_invalidation(db: Pool) { + set_operator_settings(&db, r#"{"manage_schedules": false}"#).await; + assert_eq!( + emitted_payloads(&db).await, + vec![WS.to_string()], + "the payload must be the workspace id: `process_notify_event` invalidates by it" + ); + + // Rewriting the same settings must stay silent, or every unrelated save of the settings row + // would drop the entry on every replica and send them all back to the database. + set_operator_settings(&db, r#"{"manage_schedules": false}"#).await; + assert_eq!( + emitted_payloads(&db).await.len(), + 1, + "an update that leaves operator_settings unchanged must not emit" + ); +} diff --git a/backend/windmill-common/tests/runnable_list_fingerprint.rs b/backend/windmill-common/tests/runnable_list_fingerprint.rs new file mode 100644 index 0000000000..d7438f0008 --- /dev/null +++ b/backend/windmill-common/tests/runnable_list_fingerprint.rs @@ -0,0 +1,54 @@ +use sqlx::{Pool, Postgres}; +use windmill_common::runnables::runnable_list_fingerprint; + +/// MCP clients refresh their tool list when this fingerprint moves, so it must move on every +/// change to what the listing shows and stay put on writes it does not show. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fingerprint_tracks_the_live_runnable_list(db: Pool) { + let fp = || async { + runnable_list_fingerprint(&db, "test-workspace") + .await + .unwrap() + }; + let exec = |sql: &'static str| { + let db = db.clone(); + async move { sqlx::query(sql).execute(&db).await.unwrap() } + }; + + let mut last = fp().await; + let mut assert_moved = async |what: &str, moved: bool| { + let now = fp().await; + assert_eq!(now != last, moved, "{what}"); + last = now; + }; + + exec( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind) + VALUES ('test-workspace', 1, 'f/test/a', '', '', 'def main(): pass', 'test-user', 'python3', 'script')", + ) + .await; + assert_moved("deploy", true).await; + + exec("UPDATE script SET lock = '' WHERE hash = 1").await; + assert_moved("lock update", false).await; + + exec("UPDATE script SET path = 'f/test/b' WHERE hash = 1").await; + assert_moved("path move", true).await; + + exec("UPDATE script SET archived = true WHERE hash = 1").await; + assert_moved("archive", true).await; + + exec( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions) + VALUES ('test-workspace', 'f/test/flow', '', '', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[1]::bigint[])", + ) + .await; + assert_moved("flow create", true).await; + + exec("UPDATE flow SET versions = array_append(versions, 2::bigint) WHERE path = 'f/test/flow'") + .await; + assert_moved("flow redeploy", true).await; + + exec("DELETE FROM flow WHERE path = 'f/test/flow'").await; + assert_moved("flow delete", true).await; +} diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index 51a7fd4b90..d5cfd8e5a4 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -66,6 +66,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( ..Default::default() }; + let mut tag = None; let job_payload = match importer_kind.as_str() { "script" => match sqlx::query_scalar!( "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1", @@ -80,6 +81,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( let info = windmill_common::get_script_info_for_hash(None, db, w_id, hash).await?; + tag = windmill_common::scripts::dependency_job_tag(info.tag, &info.language); JobPayload::Dependencies { path: importer_path.clone(), @@ -199,7 +201,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( false, None, true, - Some("dependency".into()), + tag, None, None, None, diff --git a/backend/windmill-dep-map/src/workspace_dependencies.rs b/backend/windmill-dep-map/src/workspace_dependencies.rs index 03db1405e5..f4dd404133 100644 --- a/backend/windmill-dep-map/src/workspace_dependencies.rs +++ b/backend/windmill-dep-map/src/workspace_dependencies.rs @@ -11,6 +11,8 @@ use crate::{ #[derive(sqlx::FromRow, Clone, Serialize, Deserialize, Hash, Debug)] pub struct NewWorkspaceDependencies { + /// Trusted as-is by `create`: a caller deserializing this from a request must first check it + /// against the workspace the caller is authorized in. pub workspace_id: String, pub language: ScriptLang, pub name: Option, diff --git a/backend/windmill-jseval/src/lib.rs b/backend/windmill-jseval/src/lib.rs index 55af36f65a..55c82e1d13 100644 --- a/backend/windmill-jseval/src/lib.rs +++ b/backend/windmill-jseval/src/lib.rs @@ -57,13 +57,16 @@ const END_BRACKET_PATTERN: &str = "\"]"; // ── Regex statics ───────────────────────────────────────────────────── lazy_static! { - // `results` is fetched lazily via the `__getResult` async proxy, so we - // wrap each `results.X` access with `(await ...)` to drive the proxy. - // `flow_env` used to be wrapped here too (it was an async Deno op-backed - // proxy in the deno_core era); QuickJS now exposes flow_env as a plain - // in-memory object, so no await is needed. + // Step ids statically referenced as `results.X` / `results["X"]`. They are + // prefetched before the expression runs so `results` reads synchronously: + // rewriting accesses to `await` breaks inside non-async inner functions. + // Over-matching (e.g. inside a string literal) only costs a spurious fetch. + // Bracket keys are captured as the whole JS string literal and decoded by + // QuickJS (`__loadResults`), dropping any that fail to parse: a match may + // come from a comment. The boundary must allow `.`, or `...results.a` is + // never prefetched. static ref RE: Regex = Regex::new( - r#"(?m)(?Presults(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"# + r#"(?:^|[^a-zA-Z0-9_$])results(?:\??\.([a-zA-Z_0-9]+)|(?:\?\.)?\[("(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*')\])"# ) .unwrap(); // SQL fast-path: simple `results.X.Y[i]...` accesses are dispatched to @@ -89,9 +92,20 @@ pub fn replace_with_await(expr: String, fn_name: &str) -> String { s } +/// JS string literals naming the step ids statically read from `results`. #[cfg(feature = "quickjs")] -pub fn replace_with_await_result(expr: String) -> String { - RE.replace_all(&expr, "(await $r)").to_string() +fn referenced_step_ids(expr: &str) -> Vec { + let mut ids: Vec = RE + .captures_iter(expr) + .filter_map(|c| match (c.get(1), c.get(2)) { + (Some(ident), _) => Some(format!("\"{}\"", ident.as_str())), + (_, Some(literal)) => Some(literal.as_str().to_string()), + _ => None, + }) + .collect(); + ids.sort(); + ids.dedup(); + ids } #[cfg(feature = "quickjs")] @@ -442,11 +456,18 @@ async fn eval_quickjs_inner( let op_state_clone = op_state.clone(); let by_id_clone = by_id.clone(); - // Transform expression to add await for variable/resource/results access - let expr_with_funcs = ["variable", "resource", "flow_user_state"] + let transformed_expr = ["variable", "resource", "flow_user_state"] .into_iter() .fold(expr.to_string(), replace_with_await); - let transformed_expr = replace_with_await_result(expr_with_funcs); + let step_ids = by_id + .as_ref() + .map(|_| referenced_step_ids(expr)) + .unwrap_or_default(); + let prefetch = if step_ids.is_empty() { + "Promise.resolve()".to_string() + } else { + format!("__loadResults({})", serde_json::to_string(&step_ids)?) + }; async_with!(context => |ctx| { let globals = ctx.globals(); @@ -552,10 +573,12 @@ async fn eval_quickjs_inner( .map_err(quickjs_error_to_anyhow)?; // Determine if we need to add return statement. + // The prefetch runs outside the expression's function so user declarations + // can't shadow `__loadResults`; the expression stays a zero-arg IIFE. let code = if should_add_return_quickjs(&transformed_expr) { - format!("(async function() {{ return {}; }})().then((x) => JSON.stringify(x ?? null))", transformed_expr) + format!("{}.then(() => (async function() {{ return {}; }})()).then((x) => JSON.stringify(x ?? null))", prefetch, transformed_expr) } else { - format!("(async function() {{ {} }})().then((x) => JSON.stringify(x ?? null))", transformed_expr) + format!("{}.then(() => (async function() {{ {} }})()).then((x) => JSON.stringify(x ?? null))", prefetch, transformed_expr) }; // Evaluate the expression (returns a Promise that resolves to a JSON string) @@ -802,16 +825,37 @@ fn setup_results_proxy<'js>( .map_err(quickjs_error_to_anyhow)?; } + // Prefetched steps read synchronously; a fetch error is rethrown only when + // the step is actually read. Other (dynamic) names still return a Promise. let proxy_setup = r#" + function __resolveResult(name) { + if (name === __previous_id && typeof previous_result !== 'undefined') { + return Promise.resolve(previous_result); + } + return __getResult(name); + } + const __loaded = new Map(); + async function __loadResults(literals) { + const ids = new Set(); + for (const lit of literals) { + try { ids.add((0, eval)(lit)); } catch {} + } + await Promise.all([...ids].map((id) => __resolveResult(id).then( + (v) => __loaded.set(id, { v }), + (e) => __loaded.set(id, { e }), + ))); + } const results = new Proxy({}, { get: function(target, name, receiver) { if (typeof name === 'symbol') { return undefined; } - if (name === __previous_id && typeof previous_result !== 'undefined') { - return Promise.resolve(previous_result); + const r = __loaded.get(name); + if (r) { + if ('e' in r) throw r.e; + return r.v; } - return __getResult(name); + return __resolveResult(name); } }); "#; @@ -1116,19 +1160,94 @@ mod tests { } #[test] - fn test_replace_with_await_result() { + fn test_referenced_step_ids() { assert_eq!( - replace_with_await_result("results.step_a".to_string()), - "(await results.step_a)" + referenced_step_ids(r#"results.b + results?.["a"] + results['c'] + my_results.e"#), + vec![r#""a""#, r#""b""#, "'c'"] + ); + assert!(referenced_step_ids("// results[\"\n\"]").is_empty()); + assert!(referenced_step_ids("no_results_here").is_empty()); + assert_eq!(referenced_step_ids("{...results.a}"), vec![r#""a""#]); + } + + /// `results.a` resolves to `previous_result` (step `a` is the previous + /// step), so no client is needed. + async fn eval_with_results(expr: &str) -> anyhow::Result { + let mut ctx = HashMap::new(); + ctx.insert( + "previous_result".to_string(), + Arc::new(to_raw_value(&json!({"x": 1, "y": 2}))), + ); + let mut fi = HashMap::new(); + fi.insert("ids".to_string(), to_raw_value(&json!(["y", "x"]))); + let by_id = IdContext { + flow_job: Uuid::nil(), + root_flow_job: Uuid::nil(), + steps_results: HashMap::new(), + previous_id: "a".to_string(), + }; + let result = eval_timeout_quickjs( + expr.to_string(), + ctx, + Some(mappable_rc::Marc::new(fi)), + None, + None, + Some(&by_id), + None, + ) + .await?; + Ok(serde_json::from_str(result.get())?) + } + + #[tokio::test] + async fn test_results_in_nested_functions() { + assert_eq!( + eval_with_results("return (() => { const tm = results.a; return tm.x + tm.y; })();") + .await + .unwrap(), + json!(3) ); assert_eq!( - replace_with_await_result("results.a + results.b".to_string()), - "(await results.a) + (await results.b)" + eval_with_results("flow_input.ids.map(id => results.a[id])") + .await + .unwrap(), + json!([2, 1]) ); assert_eq!( - replace_with_await_result("no_results_here".to_string()), - "no_results_here" + eval_with_results(r#""results.a is " + JSON.stringify({...results.a})"#) + .await + .unwrap(), + json!(r#"results.a is {"x":1,"y":2}"#) ); + assert_eq!( + eval_with_results( + r#"[0].map(() => results["\x61"].x + results?.['a'].y + results.a.y)"# + ) + .await + .unwrap(), + json!([5]) + ); + assert_eq!( + eval_with_results(r#"results.a.x /* results["\xZZ"] */"#) + .await + .unwrap(), + json!(1) + ); + assert_eq!( + eval_with_results( + "function __loadResults() {}\nreturn [results.a.x, arguments.length];" + ) + .await + .unwrap(), + json!([1, 0]) + ); + // A failed prefetch only throws when that step is actually read. + assert_eq!( + eval_with_results("true ? 1 : results.other").await.unwrap(), + json!(1) + ); + let err = eval_with_results("results.other").await.unwrap_err(); + assert!(err.to_string().contains("Result fetching not available")); } #[test] diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7ca1eb08a1..4801bfaa99 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -208,6 +208,11 @@ pub trait McpBackend: Send + Sync + Clone + 'static { workspace_id: &str, ) -> BackendResult; + /// An opaque value that changes whenever the workspace's live scripts or flows are + /// added, removed, moved or redeployed, compared across polls to detect tool-list + /// changes. + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/list_changes.rs b/backend/windmill-mcp/src/server/list_changes.rs new file mode 100644 index 0000000000..8470cff630 --- /dev/null +++ b/backend/windmill-mcp/src/server/list_changes.rs @@ -0,0 +1,104 @@ +//! Detects changes to a workspace's scripts and flows for `subscriptions/listen`. +//! +//! Each process polls a fingerprint of every workspace one of its listeners watches, +//! so a change made on any replica, through any path (UI, CLI, git sync, a user +//! rename), reaches every listener within one poll interval with no cross-replica +//! signalling. + +use crate::server::backend::McpBackend; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, Notify}; + +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +struct Watcher { + changes: broadcast::Sender<()>, + poll_now: Arc, +} + +#[derive(Default)] +pub(crate) struct ListChanges { + watchers: Mutex>, +} + +impl ListChanges { + /// Subscribe to `workspace_id`'s changes, starting its poller if this is the + /// first listener for it in this process. + pub(crate) fn subscribe( + self: &Arc, + backend: &Arc, + workspace_id: &str, + ) -> broadcast::Receiver<()> { + let mut watchers = self.watchers.lock().unwrap(); + if let Some(watcher) = watchers.get(workspace_id) { + return watcher.changes.subscribe(); + } + let (changes, rx) = broadcast::channel(16); + let poll_now = Arc::new(Notify::new()); + watchers.insert( + workspace_id.to_string(), + Watcher { changes: changes.clone(), poll_now: poll_now.clone() }, + ); + tokio::spawn(self.clone().poll( + backend.clone(), + workspace_id.to_string(), + changes, + poll_now, + )); + rx + } + + /// Poll `workspace_id` now rather than at its next interval, after this process + /// changed it: the client that made the change is the one waiting on it. Polling + /// rather than signalling directly keeps the poller's baseline current, so the + /// same change is not reported again on the next tick. + pub(crate) fn poll_now(&self, workspace_id: &str) { + if let Some(watcher) = self.watchers.lock().unwrap().get(workspace_id) { + watcher.poll_now.notify_one(); + } + } + + async fn poll( + self: Arc, + backend: Arc, + workspace_id: String, + changes: broadcast::Sender<()>, + poll_now: Arc, + ) { + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = None; + loop { + tokio::select! { + _ = interval.tick() => {} + _ = poll_now.notified() => {} + } + { + // Checked under the lock `subscribe` takes, so a listener cannot join a + // watcher that is about to stop. + let mut watchers = self.watchers.lock().unwrap(); + if changes.receiver_count() == 0 { + watchers.remove(&workspace_id); + return; + } + } + match backend.runnable_list_fingerprint(&workspace_id).await { + Ok(fingerprint) => { + // The first fingerprint also notifies: the listener that started this + // watcher has already sent its opening notification, and a change landing + // before this baseline would otherwise never be announced. + if last.as_ref() != Some(&fingerprint) { + let _ = changes.send(()); + } + last = Some(fingerprint); + } + Err(e) => tracing::warn!( + "MCP tool-list poll failed for workspace {workspace_id}: {}", + e.message + ), + } + } + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index b97e374e98..ad7c14d320 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod backend; pub mod endpoints; +mod list_changes; pub mod runner; pub mod tools; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index e27eaa8470..177989a82e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -16,20 +16,22 @@ use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool, }; +use crate::server::list_changes::ListChanges; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{RequestContext, RoleServer, SubscriptionContext}; use rmcp::ErrorData; use serde_json::Value; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the @@ -46,15 +48,21 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// leaves them unset, and a strict client rejects the response without them. /// /// Zero because nothing here is cacheable: the listing is rebuilt from the -/// workspace's scripts and flows, which change at any time, and this server -/// advertises no `listChanged` capability, so a client that cached a stale list -/// would have no way to learn it had gone stale. +/// workspace's scripts and flows, which change at any time, and `listChanged` +/// covers only some of what the listing depends on (not resources, favorites or +/// permissions), lags by up to a poll interval, and reaches only clients that hold a +/// `subscriptions/listen` stream. const LIST_TTL_MS: u64 = 0; + /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +/// How long a `subscriptions/listen` stream waits after a change before notifying, +/// collecting the rest of the burst into the same notification. +const LIST_CHANGE_COALESCE: std::time::Duration = std::time::Duration::from_millis(500); + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -83,11 +91,12 @@ fn truncate_tool_result(text: String) -> String { /// to perform the actual operations (database queries, job execution, etc.) pub struct Runner { backend: Arc, + list_changes: Arc, } impl Clone for Runner { fn clone(&self) -> Self { - Self { backend: self.backend.clone() } + Self { backend: self.backend.clone(), list_changes: self.list_changes.clone() } } } @@ -113,7 +122,7 @@ struct McpContext { impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { - Self { backend: Arc::new(backend) } + Self { backend: Arc::new(backend), list_changes: Arc::default() } } /// Extract authentication, the workspace mode and the HTTP request itself @@ -232,6 +241,22 @@ fn endpoint_path_policy(endpoint_name: &str) -> Option { } } +/// Endpoint tools that change which scripts/flows a workspace exposes, or their +/// schemas. The process serving the call polls the workspace right away rather than +/// at its next interval, so a client that just deployed a script can call it. +fn changes_runnable_list(endpoint_name: &str) -> bool { + matches!( + endpoint_name, + "createScript" + | "updateScript" + | "deleteScriptByPath" + | "deleteScriptByHash" + | "createFlow" + | "updateFlow" + | "deleteFlowByPath" + ) +} + /// Whether the token restricts `kind` ("script"/"flow") to specific paths. A /// `*` pattern grants every path (see `is_resource_allowed`), so it does not /// count as confinement. @@ -428,12 +453,17 @@ impl ServerHandler for Runner { .with_title("Windmill") .with_website_url("https://windmill.dev"); - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(server_info) - .with_instructions( - "This server provides a list of scripts and flows the user can run on Windmill. \ + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(server_info) + .with_instructions( + "This server provides a list of scripts and flows the user can run on Windmill. \ Each flow and script is a tool callable with their respective arguments.", - ) + ) } /// Pinned rather than left to rmcp's default (every version the SDK knows), so a @@ -444,6 +474,84 @@ impl ServerHandler for Runner { Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } + /// Only clients below `2026-07-28` send `initialize`, and those receive server + /// notifications on a session's standalone stream. The transport is sessionless, so + /// there is none: `listChanged` is withheld from them rather than promised and + /// never delivered. `2026-07-28` clients learn it from `server/discover` and + /// receive it on `subscriptions/listen` (see `listen`). + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + if let Some(tools) = info.capabilities.tools.as_mut() { + tools.list_changed = None; + } + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + /// `None` for a request that asks for nothing this server sends, so the client is + /// told the subscription is unsupported rather than acked with an empty filter. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (requested.tools_list_changed == Some(true)) + .then(|| SubscriptionFilter::builder().tools_list_changed().build()) + } + + async fn listen(&self, subscription: SubscriptionContext) -> Result<(), ErrorData> { + let McpContext { auth, mode, .. } = Self::extract_context(subscription.request_context())?; + // Multi-workspace mode and read-only tokens list no scripts or flows, so their + // tool list never changes. + let workspace_id = match mode { + McpMode::Single(workspace_id) if !auth.read_only() => workspace_id, + _ => { + subscription.cancelled().await; + return Ok(()); + } + }; + + let mut changes = self.list_changes.subscribe(&self.backend, &workspace_id); + // Every stream opens with one notification: a change the watcher recorded between + // this client's `tools/list` and this subscription is never broadcast again. + let mut opening = true; + loop { + if !std::mem::take(&mut opening) { + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + change = changes.recv() => match change { + Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + } + } + // A client creating several scripts in a row: collapse the burst into one + // notification, so it re-lists once. + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + _ = tokio::time::sleep(LIST_CHANGE_COALESCE) => {} + } + while !matches!( + changes.try_recv(), + Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) + ) {} + if subscription + .sink() + .notify_tool_list_changed() + .await + .is_err() + { + return Ok(()); + } + } + } + async fn list_tools( &self, _request: Option, @@ -714,6 +822,9 @@ impl Runner { .backend .call_endpoint(auth, workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(workspace_id); + } return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( @@ -969,6 +1080,9 @@ impl Runner { .backend .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(&workspace_id); + } Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( diff --git a/backend/windmill-object-store/Cargo.toml b/backend/windmill-object-store/Cargo.toml index 90af4f7e7c..f80eb88881 100644 --- a/backend/windmill-object-store/Cargo.toml +++ b/backend/windmill-object-store/Cargo.toml @@ -11,6 +11,7 @@ enterprise = [] parquet = [ "windmill-common/parquet", "dep:object_store", + "dep:reqwest_object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion", @@ -48,6 +49,7 @@ globset.workspace = true axum.workspace = true object_store = { workspace = true, optional = true } +reqwest_object_store = { workspace = true, optional = true } aws-sdk-sts = { workspace = true, optional = true } aws-smithy-types-convert = { workspace = true, optional = true } datafusion = { workspace = true, optional = true } diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index edc3ea9149..a9551ad835 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -583,8 +583,175 @@ pub async fn ambient_aws_credentials( ambient_aws_credentials_provider(region).await.get().await } +/// An object-store HTTP connector whose client cannot reach a non-public address. +/// +/// Validating a caller-supplied endpoint up front does not bind the connection: object_store +/// resolves the host again when it connects, so a name can answer a public address to the check +/// and an internal one to the connect. This client resolves every host through +/// [`PublicOnlyResolver`], which judges the very addresses it hands to the connect, refuses IP-literal +/// hosts that are not public (they never reach a resolver), and follows no redirect. +/// +/// The environment's egress proxy (`HTTP(S)_PROXY`/`ALL_PROXY`) is still used, and its own host is +/// exempt from the resolver check: it is operator configuration and typically internal. A request +/// whose target is the proxy host itself is refused, so the exemption only ever serves the proxy +/// hop. A proxied request's target is resolved by the proxy, out of this client's reach (see +/// `ssrf::ValidatedTarget`). +#[cfg(feature = "parquet")] +#[derive(Debug, Clone, Copy)] +pub struct PublicOnlyConnector; + +#[cfg(feature = "parquet")] +impl object_store::client::HttpConnector for PublicOnlyConnector { + fn connect( + &self, + options: &ClientOptions, + ) -> object_store::Result { + let allow_http = options + .get_config_value(&object_store::ClientConfigKey::AllowHttp) + .is_some_and(|v| v == "true"); + let proxy_hosts: Arc<[String]> = system_proxy_hosts().into(); + let client = reqwest_object_store::Client::builder() + .dns_resolver(Arc::new(PublicOnlyResolver { + proxy_hosts: proxy_hosts.clone(), + })) + .redirect(reqwest_object_store::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(5)) + .default_headers(HeaderMap::from_iter([( + reqwest::header::ACCEPT_ENCODING, + reqwest::header::HeaderValue::from_static(""), + )])) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .https_only(!allow_http) + .build() + .map_err(|e| object_store::Error::Generic { + store: "HTTP client", + source: Box::new(e), + })?; + Ok(object_store::client::HttpClient::new(PublicOnlyClient { + client, + proxy_hosts, + })) + } +} + +#[cfg(feature = "parquet")] +#[derive(Debug)] +struct PublicOnlyClient { + client: reqwest_object_store::Client, + proxy_hosts: Arc<[String]>, +} + +#[cfg(feature = "parquet")] +#[async_trait] +impl object_store::client::HttpService for PublicOnlyClient { + async fn call( + &self, + req: object_store::client::HttpRequest, + ) -> Result { + let host = req.uri().host().unwrap_or_default(); + let literal = host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok(); + let refused = if literal.is_some_and(|ip| windmill_common::ssrf::is_private_ip(&ip)) { + Some("a private, loopback, or link-local address") + } else if self + .proxy_hosts + .iter() + .any(|p| p.eq_ignore_ascii_case(host)) + { + Some("the egress proxy") + } else { + None + }; + if let Some(what) = refused { + return Err(object_store::client::HttpError::new( + object_store::client::HttpErrorKind::Unknown, + std::io::Error::other(format!("'{host}' is {what}")), + )); + } + object_store::client::HttpService::call(&self.client, req).await + } +} + +#[cfg(feature = "parquet")] +struct PublicOnlyResolver { + proxy_hosts: Arc<[String]>, +} + +/// The hosts of the proxies reqwest picks up from the environment. +#[cfg(feature = "parquet")] +fn system_proxy_hosts() -> Vec { + [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ] + .into_iter() + .filter_map(|k| std::env::var(k).ok()) + .filter_map(|v| { + let v = v.trim(); + let url = if v.contains("://") { + v.to_string() + } else { + format!("http://{v}") + }; + Some( + reqwest::Url::parse(&url) + .ok()? + .host_str()? + .to_ascii_lowercase(), + ) + }) + .collect() +} + +#[cfg(feature = "parquet")] +impl reqwest_object_store::dns::Resolve for PublicOnlyResolver { + fn resolve( + &self, + name: reqwest_object_store::dns::Name, + ) -> reqwest_object_store::dns::Resolving { + let is_proxy = self + .proxy_hosts + .iter() + .any(|p| p.eq_ignore_ascii_case(name.as_str())); + Box::pin(async move { + let host = name.as_str(); + let addrs: Vec = + tokio::net::lookup_host((host, 0)).await?.collect(); + if !is_proxy + && addrs + .iter() + .any(|a| windmill_common::ssrf::is_private_ip(&a.ip())) + { + return Err(format!( + "'{host}' resolves to a private, loopback, or link-local address" + ) + .into()); + } + Ok(Box::new(addrs.into_iter()) as reqwest_object_store::dns::Addrs) + }) + } +} + #[cfg(feature = "parquet")] pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result> { + build_s3_client_with(s3_resource_ref, None).await +} + +#[cfg(feature = "parquet")] +async fn build_s3_client_with( + s3_resource_ref: &S3Resource, + connector: Option, +) -> error::Result> { let static_creds = s3_resource_has_static_credentials(s3_resource_ref); let credentials_provider = if !static_creds { @@ -623,6 +790,9 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result error::Result error::Result> { + build_azure_blob_client_with(azure_blob_resource_ref, None) +} + +#[cfg(feature = "parquet")] +fn build_azure_blob_client_with( + azure_blob_resource_ref: &AzureBlobResource, + connector: Option, ) -> error::Result> { let blob_resource = azure_blob_resource_ref.clone(); @@ -701,6 +879,9 @@ fn build_azure_blob_client( if !blob_resource.use_ssl.unwrap_or(false) { store_builder = store_builder.with_allow_http(true) } + if let Some(connector) = connector { + store_builder = store_builder.with_http_connector(connector); + } if let Some(key) = blob_resource.access_key { if key != "" { @@ -735,6 +916,14 @@ pub fn gcs_service_account_key_is_blank(service_account_key: &str) -> bool { #[cfg(feature = "parquet")] async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result> { + build_gcs_client_with(gcs_resource_ref, None).await +} + +#[cfg(feature = "parquet")] +async fn build_gcs_client_with( + gcs_resource_ref: &GcsResource, + connector: Option, +) -> error::Result> { let gcs_resource = gcs_resource_ref.clone(); let mut store_builder = GoogleCloudStorageBuilder::new() @@ -754,6 +943,9 @@ async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result String { pub async fn build_object_store_from_settings( settings: ObjectSettings, init_private_key: Option<&windmill_common::DB>, +) -> error::Result { + build_object_store_from_settings_with(settings, init_private_key, None).await +} + +/// [`build_object_store_from_settings`] for a caller-supplied endpoint: the client refuses to +/// connect to any non-public address (see [`PublicOnlyConnector`]). Filesystem and OIDC settings +/// are refused; whether S3, Azure or GCS settings carry explicit credentials (rather than falling +/// back to the server's ambient ones) is still the caller's to check. +#[cfg(feature = "parquet")] +pub async fn build_public_object_store_from_settings( + settings: ObjectSettings, +) -> error::Result> { + match settings { + ObjectSettings::S3(_) | ObjectSettings::Azure(_) | ObjectSettings::Gcs(_) => { + build_object_store_from_settings_with(settings, None, Some(PublicOnlyConnector)) + .await + .map(|x| x.store) + } + ObjectSettings::AwsOidc(_) | ObjectSettings::Filesystem(_) => { + Err(error::Error::BadRequest( + "This object storage backend cannot be restricted to public endpoints".to_string(), + )) + } + } +} + +#[cfg(feature = "parquet")] +async fn build_object_store_from_settings_with( + settings: ObjectSettings, + init_private_key: Option<&windmill_common::DB>, + connector: Option, ) -> error::Result { let located = |store: Arc, resource: ObjectStoreResource| ExpirableObjectStore { @@ -946,12 +1169,14 @@ pub async fn build_object_store_from_settings( match settings { ObjectSettings::S3(s3_settings) => { let s3_resource = s3_resource_from_settings(s3_settings); - build_s3_client(&s3_resource) + build_s3_client_with(&s3_resource, connector) .await .map(|x| located(x, ObjectStoreResource::S3(s3_resource))) } - ObjectSettings::Azure(azure_settings) => build_azure_blob_client(&azure_settings) - .map(|x| located(x, ObjectStoreResource::Azure(azure_settings))), + ObjectSettings::Azure(azure_settings) => { + build_azure_blob_client_with(&azure_settings, connector) + .map(|x| located(x, ObjectStoreResource::Azure(azure_settings))) + } ObjectSettings::AwsOidc(ref s3_aws_oidc_settings) => { let token_generator = crate::job_s3_helpers_oss::TokenGenerator::AsServerInstance(); let res = crate::job_s3_helpers_oss::generate_s3_aws_oidc_resource( @@ -969,7 +1194,7 @@ pub async fn build_object_store_from_settings( location: Some(object_store_location(&res)), }) } - ObjectSettings::Gcs(gcs_settings) => build_gcs_client(&gcs_settings) + ObjectSettings::Gcs(gcs_settings) => build_gcs_client_with(&gcs_settings, connector) .await .map(|x| located(x, ObjectStoreResource::Gcs(gcs_settings))), ObjectSettings::Filesystem(fs) => build_filesystem_client(&fs.root_path) @@ -2701,6 +2926,44 @@ mod tests { assert_ne!(a.location, b.location); } + /// The restricted store judges the addresses it connects to, not only what an earlier check + /// resolved: neither a hostname that resolves to loopback nor a loopback literal gets a + /// connection. + #[cfg(feature = "parquet")] + #[tokio::test] + async fn test_public_store_refuses_to_connect_to_private_address() { + use futures::StreamExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + for endpoint in ["localhost", "127.0.0.1"] { + let settings = ObjectSettings::S3(S3Settings { + bucket: Some("windmill".to_string()), + region: Some("us-east-1".to_string()), + access_key: Some("key".to_string()), + secret_key: Some("secret".to_string()), + endpoint: Some(endpoint.to_string()), + allow_http: Some(true), + path_style: Some(true), + store_logs: None, + port: Some(listener.local_addr().unwrap().port()), + }); + let store = build_public_object_store_from_settings(settings) + .await + .unwrap(); + // The listener never answers, so a store that connects waits forever: bound it. + let err = + tokio::time::timeout(std::time::Duration::from_secs(30), store.list(None).next()) + .await + .unwrap_or_else(|_| panic!("the store connected to {endpoint}")) + .unwrap() + .unwrap_err(); + assert!( + format!("{err:?}").contains("private, loopback, or link-local"), + "{endpoint}: {err:?}" + ); + } + } + // --- get_logs_from_store test --- #[cfg(feature = "parquet")] diff --git a/backend/windmill-queue/src/flow_status.rs b/backend/windmill-queue/src/flow_status.rs index e6b64c659d..55d2f6ec90 100644 --- a/backend/windmill-queue/src/flow_status.rs +++ b/backend/windmill-queue/src/flow_status.rs @@ -15,6 +15,9 @@ pub async fn update_flow_status_in_progress( let step = get_step_of_flow_status(db, flow).await?; match step { Step::Step { idx: step, .. } => { + // Children of a parallel module start concurrently and are tracked in `flow_jobs`, so its + // `job` would only hold whichever child started last. Writing it rewrites the parent's + // whole flow_status under its row lock once per child, serializing large parallel loops. sqlx::query!( "UPDATE v2_job_status SET flow_status = jsonb_set( @@ -22,7 +25,11 @@ pub async fn update_flow_status_in_progress( ARRAY['modules', $3::INTEGER::TEXT, 'type'], to_jsonb('InProgress'::text) ) - WHERE id = $2", + WHERE id = $2 + AND NOT COALESCE( + flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}', + false + )", job_in_progress, flow, step as i32 diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 50eb9c8b1f..880b72cc0e 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1007,7 +1007,7 @@ pub async fn add_completed_job( )); } - // Native script retry: a failed `Script` job that carries a retry policy and + // Native script retry: a failed `Script` or `Script_Hub` job that carries a retry policy and // has attempts left gets its next attempt enqueued here — before the queue // row (which holds the attempt counter) is removed by commit. The failed // attempt is still recorded as a completed job below. `maybe_enqueue_…` @@ -1077,9 +1077,9 @@ pub async fn add_completed_job( // Auto-resolve a retry chain that ultimately worked, from whichever of the two // completions lands last (see resolve_retry_chain_if_succeeded): a success that has a // parent (so is a possible retry attempt), or a failure that just enqueued a retry. - // `retry_pending` already implies a non-flow-step `Script`. + // `retry_pending` already implies a non-flow-step `Script` or `Script_Hub`. let resolve_root = if success && !skipped && !completed_job.is_flow_step() { - matches!(completed_job.kind, JobKind::Script) + matches!(completed_job.kind, JobKind::Script | JobKind::Script_Hub) .then(|| completed_job.parent_job) .flatten() } else if !success && !skipped && retry_pending { @@ -1177,74 +1177,76 @@ async fn commit_completed_job( let serialized_result = result.serialized_json(); let sanitized_result = strip_json_nul(serialized_result.as_ref()); + let labels = result.wm_labels(); + let is_scheduled = + completed_job.schedule_path().is_some() && completed_job.runnable_path.is_some(); + let wac_parent = (!completed_job.is_flow_step()) + .then_some(completed_job.parent_job) + .flatten(); + let monitor_parent = (completed_job.is_flow_step() && flow_is_done) + .then_some(completed_job.parent_job) + .flatten(); + let completion = Completion { + completed_job, + success, + skipped, + result: sanitized_result.as_ref(), + result_columns, + mem_peak, + canceled_by, + duration, + }; + + if labels.is_none() + && !has_concurrent_limit + && wac_parent.is_none() + && monitor_parent.is_none() + && !is_scheduled + { + let Some(duration) = completion.execute(&mut *db.acquire().await?).await? else { + return Err(not_in_queue_error(db, job_id).await); + }; + log_completed_job(completed_job, duration, success); + return Ok((None, duration, false, false)); + } + let mut tx = db.begin().warn_after_seconds(10).await?; - let duration = sqlx::query_scalar!( - "INSERT INTO v2_job_completed AS cj - ( workspace_id - , id - , started_at - , duration_ms - , result - , result_columns - , canceled_by - , canceled_reason - , flow_status - , workflow_as_code_status - , memory_peak - , status - , worker - ) - SELECT q.workspace_id, q.id, started_at, COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000), $3::text::jsonb, $10, $5, $6, - flow_status, workflow_as_code_status, - $8, CASE WHEN $4::BOOL THEN 'canceled'::job_status - WHEN $7::BOOL THEN 'skipped'::job_status - WHEN $2::BOOL THEN 'success'::job_status - ELSE 'failure'::job_status END AS status, - q.worker - FROM v2_job_queue q LEFT JOIN v2_job_status USING (id) WHERE q.id = $1 - ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb RETURNING duration_ms AS \"duration_ms!\"", - /* $1 */ completed_job.id, - /* $2 */ success, - /* $3 */ sanitized_result.as_ref(), - /* $4 */ canceled_by.is_some(), - /* $5 */ canceled_by.clone().map(|cb| cb.username).flatten(), - /* $6 */ canceled_by.clone().map(|cb| cb.reason).flatten(), - /* $7 */ skipped, - /* $8 */ if mem_peak > 0 { Some(mem_peak) } else { None }, - /* $9 */ duration, - /* $10 */ result_columns as Option<&Vec>, + // The parent's rows are locked ahead of the child's own queue row (see + // `record_child_completion` for the order this must keep), so the duration it stamps is read + // before the completion: the one the completed row will hold. `now()` is fixed for the + // transaction, and a completed row already there keeps its own duration. + let mut wac_parent_ready = false; + if let Some(parent_job) = wac_parent { + let Some(duration) = sqlx::query_scalar!( + "SELECT COALESCE(c.duration_ms, COALESCE($2::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(q.started_at, now()))))*1000)::bigint) AS \"duration_ms!\" + FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1", + job_id, + duration, ) .fetch_optional(&mut *tx) .warn_after_seconds(10) - .await - .map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?; - - let duration = if let Some(duration) = duration { - duration - } else { - let already_inserted = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1)", - job_id + .await? + else { + return Err(not_in_queue_error(&mut *tx, job_id).await); + }; + wac_parent_ready = windmill_common::wac::record_child_completion( + &mut tx, + &parent_job, + &completed_job.id, + success, + duration, + sanitized_result.as_ref(), ) - .fetch_one(&mut *tx) .warn_after_seconds(10) - .await - .map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))? - .unwrap_or(false); + .await?; + } - if already_inserted { - return Err(Error::AlreadyCompleted(format!( - "The queued job {job_id} is already completed." - ))); - } else { - return Err(Error::AlreadyCompleted(format!( - "There is no queued job anymore for {job_id} but there is no completed job either." - ))); - } + let Some(duration) = completion.execute(&mut *tx).await? else { + return Err(not_in_queue_error(&mut *tx, job_id).await); }; - if let Some(mut labels) = result.wm_labels() { + if let Some(mut labels) = labels { // A `\u0000` inside a wm_labels entry decodes to a real NUL that the // `text[]` column rejects, which would abort this same transaction (and // roll back the sanitized result insert) exactly like an unsanitized @@ -1266,78 +1268,19 @@ async fn commit_completed_job( .map_err(|e| Error::InternalErr(format!("Could not update job labels: {e:#}")))?; } - // Before `delete_job`: the parent's rows are locked ahead of the child's own - // queue row (see `record_child_completion` for the order this must keep). - let mut wac_parent_ready = false; - if !completed_job.is_flow_step() { - if let Some(parent_job) = completed_job.parent_job { - wac_parent_ready = windmill_common::wac::record_child_completion( - &mut tx, - &parent_job, - &completed_job.id, - success, - duration, - sanitized_result.as_ref(), - ) - .warn_after_seconds(10) - .await?; - } - } - let mut _skip_downstream_error_handlers = false; - let (ntx, canceled_at_delete) = delete_job(tx, &job_id).warn_after_seconds(10).await?; - tx = ntx; - // `canceled_by` is only what the worker last read from the queue row. Deleting the row waits - // for a cancel still being written, so the row it removed is the final word on whether this - // job was canceled. - if canceled_by.is_none() { - if let Some(canceled) = canceled_at_delete { - sqlx::query!( - "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, \ - canceled_reason = $3 WHERE id = $1", - job_id, - canceled.username, - canceled.reason, - ) - .execute(&mut *tx) - .warn_after_seconds(10) - .await?; - } - } - // tracing::error!("3 {:?}", start.elapsed()); - if completed_job.is_flow_step() { - if let Some(parent_job) = completed_job.parent_job { - // persist the flow last progress timestamp to avoid zombie flow jobs - tracing::debug!( - "Persisting flow last progress timestamp to flow job: {:?}", - parent_job - ); - sqlx::query!( - "UPDATE v2_job_runtime r SET - ping = now() - FROM v2_job_queue q - WHERE r.id = $1 AND q.id = r.id - AND q.workspace_id = $2 - AND canceled_by IS NULL", + if let Some(parent_job) = monitor_parent { + let r = sqlx::query_scalar!( + "UPDATE parallel_monitor_lock SET last_ping = now() WHERE parent_flow_id = $1 and job_id = $2 RETURNING 1", parent_job, - &completed_job.workspace_id - ) - .execute(&mut *tx) - .warn_after_seconds(10) - .await?; - if flow_is_done { - let r = sqlx::query_scalar!( - "UPDATE parallel_monitor_lock SET last_ping = now() WHERE parent_flow_id = $1 and job_id = $2 RETURNING 1", - parent_job, - &completed_job.id - ).fetch_optional(&mut *tx).warn_after_seconds(10).await?; - if r.is_some() { - tracing::info!( - "parallel flow iteration is done, setting parallel monitor last ping lock for job {}", - &completed_job.id - ); - } + &completed_job.id + ).fetch_optional(&mut *tx).warn_after_seconds(10).await?; + if r.is_some() { + tracing::info!( + "parallel flow iteration is done, setting parallel monitor last ping lock for job {}", + &completed_job.id + ); } } } else { @@ -1509,6 +1452,216 @@ async fn commit_completed_job( tx.commit().warn_after_seconds(10).await?; + log_completed_job(completed_job, duration, success); + // tracing::info!("completed job: {:?}", start.elapsed().as_micros()); + Ok(( + None, + duration, + _skip_downstream_error_handlers, + wac_parent_ready, + )) +} + +/// What a job's completion writes, for `Completion::execute`. +struct Completion<'a> { + completed_job: &'a MiniCompletedJob, + success: bool, + skipped: bool, + result: &'a str, + result_columns: Option<&'a Vec>, + mem_peak: i32, + canceled_by: &'a Option, + duration: Option, +} + +impl Completion<'_> { + /// Moves the job from the queue to the completed jobs and refreshes a flow step's parent + /// ping, as one statement. Returns `None` when the job was no longer in the queue. + /// + /// The completion takes its cancellation from the queue row it deletes, not only from + /// `canceled_by`: that is what the worker last read, and the delete waits for a cancel still + /// being written, so the deleted row is the final word on whether the job was canceled. + /// + /// It locks the queue row before the completed row's key. Any other writer completing a job + /// (the monitor's zombie fallback, debounce) must take them in the same order, or the two + /// deadlock. + async fn execute(&self, conn: &mut sqlx::PgConnection) -> error::Result> { + let Completion { + completed_job, + success, + skipped, + result, + result_columns, + mem_peak, + canceled_by, + duration, + } = *self; + #[cfg(feature = "prometheus")] + if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + QUEUE_DELETE_COUNT.inc(); + } + otel_incr_queue_delete_count(); + let err = |e: sqlx::Error| { + Error::internal_err(format!( + "Could not add completed job {}: {e:#}", + completed_job.id + )) + }; + // A step's completion is progress of its flow, and keeps the flow from being reaped as a + // zombie. Any other completion runs the statement without the ping: Postgres sets up every + // write of a plan, so an unused ping would cost about a tenth of the completion. The two + // statements differ only by the ping; a change to the delete or the insert goes in both. + let Some(parent_to_ping) = completed_job + .is_flow_step() + .then_some(completed_job.parent_job) + .flatten() + else { + return sqlx::query_scalar!( + "WITH deleted AS ( + DELETE FROM v2_job_queue WHERE id = $1 + RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason + ), completed AS ( + INSERT INTO v2_job_completed AS cj + ( workspace_id + , id + , started_at + , duration_ms + , result + , result_columns + , canceled_by + , canceled_reason + , flow_status + , workflow_as_code_status + , memory_peak + , status + , worker + ) + SELECT d.workspace_id, d.id, d.started_at, + COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000), + $3::text::jsonb, $10, + CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END, + CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END, + s.flow_status, s.workflow_as_code_status, $8, + CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status + WHEN $7::BOOL THEN 'skipped'::job_status + WHEN $2::BOOL THEN 'success'::job_status + ELSE 'failure'::job_status END, + d.worker + FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb, + canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL + THEN EXCLUDED.canceled_by ELSE cj.canceled_by END, + canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL + THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END + RETURNING duration_ms + ) + SELECT duration_ms AS \"duration_ms!\" FROM completed", + /* $1 */ completed_job.id, + /* $2 */ success, + /* $3 */ result, + /* $4 */ canceled_by.is_some(), + /* $5 */ canceled_by.as_ref().and_then(|cb| cb.username.as_deref()), + /* $6 */ canceled_by.as_ref().and_then(|cb| cb.reason.as_deref()), + /* $7 */ skipped, + /* $8 */ if mem_peak > 0 { Some(mem_peak) } else { None }, + /* $9 */ duration, + /* $10 */ result_columns as Option<&Vec>, + ) + .fetch_optional(&mut *conn) + .warn_after_seconds(10) + .await + .map_err(err); + }; + // A canceled flow is pinged too: it is completed by its next transition like any other + // flow, and the zombie flow monitor needs the ping to finish the cancel if that + // transition is lost. + sqlx::query_scalar!( + "WITH deleted AS ( + DELETE FROM v2_job_queue WHERE id = $1 + RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason + ), completed AS ( + INSERT INTO v2_job_completed AS cj + ( workspace_id + , id + , started_at + , duration_ms + , result + , result_columns + , canceled_by + , canceled_reason + , flow_status + , workflow_as_code_status + , memory_peak + , status + , worker + ) + SELECT d.workspace_id, d.id, d.started_at, + COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000), + $3::text::jsonb, $10, + CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END, + CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END, + s.flow_status, s.workflow_as_code_status, $8, + CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status + WHEN $7::BOOL THEN 'skipped'::job_status + WHEN $2::BOOL THEN 'success'::job_status + ELSE 'failure'::job_status END, + d.worker + FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb, + canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL + THEN EXCLUDED.canceled_by ELSE cj.canceled_by END, + canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL + THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END + RETURNING duration_ms + ), parent_ping AS ( + UPDATE v2_job_runtime r SET ping = now() + FROM v2_job_queue q + WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12 + AND EXISTS (SELECT 1 FROM completed) + ) + SELECT duration_ms AS \"duration_ms!\" FROM completed", + /* $1 */ completed_job.id, + /* $2 */ success, + /* $3 */ result, + /* $4 */ canceled_by.is_some(), + /* $5 */ canceled_by.as_ref().and_then(|cb| cb.username.as_deref()), + /* $6 */ canceled_by.as_ref().and_then(|cb| cb.reason.as_deref()), + /* $7 */ skipped, + /* $8 */ if mem_peak > 0 { Some(mem_peak) } else { None }, + /* $9 */ duration, + /* $10 */ result_columns as Option<&Vec>, + /* $11 */ parent_to_ping, + /* $12 */ &completed_job.workspace_id, + ) + .fetch_optional(&mut *conn) + .warn_after_seconds(10) + .await + .map_err(err) + } +} + +/// The error for a completion that found no queue row to complete. +async fn not_in_queue_error<'e>(conn: impl PgExecutor<'e>, job_id: Uuid) -> Error { + let already_inserted = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1)", + job_id + ) + .fetch_one(conn) + .warn_after_seconds(10) + .await; + match already_inserted { + Err(e) => Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")), + Ok(Some(true)) => { + Error::AlreadyCompleted(format!("The queued job {job_id} is already completed.")) + } + Ok(_) => Error::AlreadyCompleted(format!( + "There is no queued job anymore for {job_id} but there is no completed job either." + )), + } +} + +fn log_completed_job(completed_job: &MiniCompletedJob, duration: i64, success: bool) { + let job_id = completed_job.id; tracing::info!( %job_id, root_job = ?completed_job.flow_innermost_root_job.map(|x| x.to_string()).unwrap_or_else(|| String::new()), @@ -1527,13 +1680,6 @@ async fn commit_completed_job( "inserted completed job: {} (success: {success})", completed_job.id ); - // tracing::info!("completed job: {:?}", start.elapsed().as_micros()); - Ok(( - None, - duration, - _skip_downstream_error_handlers, - wac_parent_ready, - )) } async fn check_result_size( @@ -1843,10 +1989,10 @@ async fn eval_retry_if( false } -/// Native script retry. When a failed `Script` job carries a retry policy (via -/// `runnable_settings_handle`) and has attempts left, enqueue a fresh attempt of -/// the same script after the policy's backoff delay — instead of having wrapped -/// it in a one-step flow. Each attempt is a real `Script` job; the attempt +/// Native script retry. When a failed `Script` or `Script_Hub` job carries a retry +/// policy (via `runnable_settings_handle`) and has attempts left, enqueue a fresh +/// attempt of the same script after the policy's backoff delay — instead of having +/// wrapped it in a one-step flow. Each attempt is a job of the same kind; the attempt /// counter lives in the `native_retry_attempt` marker, written here and read only /// on the next failure (never on the hot job-pull path). /// @@ -1867,7 +2013,10 @@ pub async fn maybe_enqueue_native_script_retry( result_fn: &(dyn Fn() -> Option> + Sync), ) -> Result { // Only plain top-level scripts retry natively; cancellation always wins. - if canceled_by.is_some() || !matches!(job.kind, JobKind::Script) || job.is_flow_step() { + if canceled_by.is_some() + || !matches!(job.kind, JobKind::Script | JobKind::Script_Hub) + || job.is_flow_step() + { return Ok(false); } @@ -5187,46 +5336,6 @@ async fn extract_result_from_job_result( } } -/// Also reports the cancellation the deleted row carried, if any. Unlike a plain read of the queue -/// row, this waits for a cancel that is still being written, so it is the last word on one. -pub async fn delete_job<'c>( - mut tx: Transaction<'c, Postgres>, - job_id: &Uuid, -) -> windmill_common::error::Result<(Transaction<'c, Postgres>, Option)> { - #[cfg(feature = "prometheus")] - if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { - QUEUE_DELETE_COUNT.inc(); - } - otel_incr_queue_delete_count(); - - let job_removed = sqlx::query!( - "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", - job_id, - ) - .fetch_optional(&mut *tx) - .await; - - let canceled = match &job_removed { - Err(job_removed) => { - tracing::error!( - "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" - ); - None - } - Ok(None) => { - tracing::error!("Job {job_id} could not be deleted, no row was removed. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); - None - } - Ok(Some(row)) => row.canceled_by.as_ref().map(|username| CanceledBy { - username: Some(username.clone()), - reason: row.canceled_reason.clone(), - }), - }; - - tracing::debug!("Job {job_id} deleted"); - Ok((tx, canceled)) -} - pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result { Ok(sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1 AND workspace_id = $2)", @@ -5854,10 +5963,10 @@ async fn push_inner<'c, 'd>( dedicated_worker, ..Default::default() }, - JobPayload::FlowNode { id, path } => { + JobPayload::FlowNode { id, path, no_inherited_flow_env } => { let data = cache::flow::fetch_flow(db, id).await?; let value = data.value(); - let status = Some(FlowStatus::new(value)); + let status = Some(FlowStatus { no_inherited_flow_env, ..FlowStatus::new(value) }); // Keep inserting `value` if not all workers are updated. // Starting at `v1.440`, the value is fetched on pull from the flow node id. let value_o = if !MIN_VERSION_IS_AT_LEAST_1_440.met().await { @@ -6094,6 +6203,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, } } _ => { @@ -6153,10 +6263,12 @@ async fn push_inner<'c, 'd>( // `quickjs` feature it cannot be evaluated and fails closed (no retry); // the flow path is not a fallback, since the flow runtime needs quickjs // too. + // A hub script has no hash and runs as a `Script_Hub` job. + let is_hub = hash.is_none() && path.starts_with("hub/"); let native_retry = !is_flow && skip_handler.is_none() && error_handler_path.is_none() - && hash.is_some() + && (hash.is_some() || is_hub) && language.is_some() && windmill_common::runnable_settings::min_version_supports_runnable_settings_v0() .await; @@ -6186,7 +6298,11 @@ async fn push_inner<'c, 'd>( break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), runnable_path: Some(path), - job_kind: JobKind::Script, + job_kind: if is_hub { + JobKind::Script_Hub + } else { + JobKind::Script + }, language, dedicated_worker, concurrency_settings, @@ -6488,6 +6604,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, }; let value = flow_data.value(); let priority = value.priority; @@ -7572,6 +7689,34 @@ fn reuse_completed_zombie_module(module: FlowStatusModule) -> FlowStatusModule { } } +/// Loads the flow version a restart switches to. The version id is caller-supplied and the +/// restarted job keeps the original job's path, so it must be a version of that same flow in +/// that same workspace: any other id would run foreign code under the original path. +/// This only ties the version to the flow; it does not authorize the caller. `workspace_id` +/// and `flow_path` must come from the original job, which the caller is already allowed to +/// restart. +pub async fn fetch_restart_flow_version( + db: &Pool, + workspace_id: &str, + flow_path: &str, + version: i64, +) -> Result, Error> { + let belongs = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2 AND path = $3) AS \"exists!\"", + version, + workspace_id, + flow_path, + ) + .fetch_one(db) + .await?; + if !belongs { + return Err(Error::BadRequest(format!( + "flow version {version} is not a version of flow {flow_path} in workspace {workspace_id}" + ))); + } + cache::flow::fetch_version(db, version).await +} + async fn restarted_flows_resolution( db: &Pool, workspace_id: &str, @@ -7633,9 +7778,12 @@ async fn restarted_flows_resolution( && row.job_kind == JobKind::Flow; let flow_data = if is_version_change { - // Fetch the new flow version - let new_version = flow_version.unwrap(); - cache::flow::fetch_version(db, new_version).await? + let flow_path = row.script_path.as_deref().ok_or_else(|| { + Error::BadRequest(format!( + "completed flow {completed_flow_id} has no path to restart a version of" + )) + })?; + fetch_restart_flow_version(db, workspace_id, flow_path, flow_version.unwrap()).await? } else { cache::job::fetch_flow(db, &row.job_kind, row.script_hash) .or_else(|_| { diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 7a9a36ff19..40c1292b74 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -6,6 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ +use crate::jobs::HTTP_CLIENT; use crate::push; use crate::PushIsolationLevel; use anyhow::Context; @@ -25,6 +26,7 @@ use windmill_common::jobs::OnBehalfOf; use windmill_common::runnable_settings::ConcurrencySettings; use windmill_common::runnable_settings::DebouncingSettings; use windmill_common::schedule::schedule_to_user; +use windmill_common::scripts::get_full_hub_script_by_path; use windmill_common::scripts::ScriptHash; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::WarnAfterExt; @@ -81,6 +83,8 @@ async fn get_schedule_metadata<'c>( Some(version), parsed_retry, )) + } else if schedule.script_path.starts_with("hub/") { + Ok((None, None, None, None, None, parsed_retry)) } else { let ( hash, @@ -322,6 +326,48 @@ pub async fn push_scheduled_job<'c>( None, on_behalf_of, ) + } else if schedule.script_path.starts_with("hub/") { + let tag = schedule.tag.clone().filter(|t| !t.is_empty()); + let payload = match &schedule.retry { + // The language is what lets `push` materialize this as a native retry + // instead of a flow wrapper, which would queue the next tick at start. + Some(retry) => JobPayload::SingleStepFlow { + path: schedule.script_path.clone(), + hash: None, + flow_version: None, + language: Some( + get_full_hub_script_by_path( + StripPath(schedule.script_path.clone()), + &HTTP_CLIENT, + Some(db), + ) + .await? + .language, + ), + retry: Some(serde_json::from_value::(retry.clone()).map_err(|e| { + error::Error::internal_err(format!( + "Unable to parse retry information from schedule: {e}" + )) + })?), + error_handler_path: None, + error_handler_args: None, + skip_handler: None, + args: args.clone(), + cache_ttl: None, + cache_ignore_s3_path: None, + priority: None, + tag_override: tag.clone(), + trigger_path: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + }, + None => JobPayload::ScriptHub { + path: schedule.script_path.clone(), + apply_preprocessor: false, + }, + }; + (payload, tag, None, None) } else { let ( hash, diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index 333d211765..f3ae34251e 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -1757,4 +1757,59 @@ mod schedule_push { assert_eq!(count_queued_jobs(&db).await, 1); Ok(()) } + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_push_hub_script_schedule(db: Pool) -> anyhow::Result<()> { + // Seed the hub cache so the push resolves the script without the network. + let version = "990000001"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"echo hi","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + let hub_path = format!("hub/{version}/test/echo"); + + // A retry must stay a native `script_hub` job: a flow wrapper would queue the + // next tick at start and let slow runs overlap. + let retry = serde_json::json!({ "constant": { "attempts": 2, "seconds": 1 } }); + for (path, retry, dynamic_skip) in [ + ("f/system/hub_plain", None, None), + ("f/system/hub_retry", Some(retry), None), + // A skip handler needs a flow wrapper, so its metadata lookup must not + // go through the `script` table. + ("f/system/hub_skip", None, Some("f/system/skip".to_string())), + ] { + let has_retry = retry.is_some(); + let has_skip = dynamic_skip.is_some(); + let schedule = make_schedule(|s| { + s.path = path.to_string(); + s.script_path = hub_path.clone(); + s.retry = retry; + s.dynamic_skip = dynamic_skip; + }); + let tx = db.begin().await?; + let tx = push_scheduled_job(&db, tx, &schedule, Some(&make_authed()), None).await?; + tx.commit().await?; + + let (job_kind, runnable_path, language, handle) = + sqlx::query_as::<_, (String, Option, Option, Option)>( + "SELECT j.kind::text, j.runnable_path, j.script_lang::text, q.runnable_settings_handle + FROM v2_job j JOIN v2_job_queue q ON j.id = q.id WHERE j.trigger = $1", + ) + .bind(path) + .fetch_one(&db) + .await?; + assert_eq!(runnable_path.as_deref(), Some(hub_path.as_str())); + if has_skip { + assert_eq!(job_kind, "singlestepflow"); + } else { + assert_eq!(job_kind, "script_hub"); + assert_eq!(language.as_deref(), Some("bash")); + assert_eq!(handle.is_some(), has_retry); + } + } + Ok(()) + } } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 5bb15b456e..48d6b19181 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -17,7 +17,9 @@ use windmill_api_auth::{ }; use windmill_common::db::DB; use windmill_common::per_minute_counter::PerMinuteCounter; -use windmill_common::ssrf::{private_git_host_allowed, private_git_host_hint, GitRemoteCaller}; +use windmill_common::ssrf::{ + is_private_ip, private_git_host_allowed, private_git_host_hint, GitRemoteCaller, +}; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; @@ -810,6 +812,7 @@ pub async fn get_resource_value_interpolated_internal<'a>( token_for_context, 0, &used_job_context, + Some(path), ) .await?; if let Some(identity) = cache_identity.as_deref() { @@ -849,10 +852,29 @@ pub async fn transform_json_value( token, depth, &used_job_context, + None, ) .await } +/// RLS resolves a reference as the token's user, not as the token: a token scoped to one +/// resource would otherwise read, through that resource, every variable or resource its +/// user can. Job tokens carry no scopes, so what a runnable resolves is unaffected. +/// `resource_path` is the resource being expanded: its own linked secrets +/// ([`is_owned_linked_var`]) are covered by the read of it. +fn check_interpolation_scope( + db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, + domain: &str, + path: &str, + resource_path: Option<&str>, +) -> Result<()> { + match db_with_opt_authed.authed() { + Some(_) if resource_path.is_some_and(|r| is_owned_linked_var(r, path)) => Ok(()), + Some(authed) => check_scopes(authed, || format!("{domain}:read:{path}")), + None => Ok(()), + } +} + /// Like [`transform_json_value`], but records into `used_job_context` whether the value /// contains a `$WM_*` contextual variable (resolved from `job_id`/`token`). A value that did /// not is job-independent and safe to cache; one that did must not be cached or shared across @@ -866,6 +888,7 @@ pub async fn transform_json_value_tracked( token: Option<&str>, depth: u8, used_job_context: &std::sync::atomic::AtomicBool, + resource_path: Option<&str>, ) -> Result { if depth >= MAX_RESOURCE_INTERPOLATION_DEPTH { return Err(Error::internal_err(format!( @@ -875,6 +898,7 @@ pub async fn transform_json_value_tracked( match v { Value::String(y) if y.starts_with("$var:") => { let path = y.strip_prefix("$var:").unwrap(); + check_interpolation_scope(db_with_opt_authed, "variables", path, resource_path)?; let v = crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false) @@ -883,6 +907,7 @@ pub async fn transform_json_value_tracked( } Value::String(y) if y.starts_with("$jsonvar:") => { let path = y.strip_prefix("$jsonvar:").unwrap(); + check_interpolation_scope(db_with_opt_authed, "variables", path, resource_path)?; let v = crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false) @@ -898,6 +923,7 @@ pub async fn transform_json_value_tracked( "Invalid resource path: {path}" ))); } + check_interpolation_scope(db_with_opt_authed, "resources", path, None)?; let mut tx: Transaction<'_, Postgres> = db_with_opt_authed.begin().await?; let v = sqlx::query_scalar!( "SELECT value from resource WHERE path = $1 AND workspace_id = $2", @@ -917,6 +943,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + Some(path), ) .await } else { @@ -1016,6 +1043,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + resource_path, ) .await?; } @@ -1040,6 +1068,7 @@ pub async fn transform_json_value_tracked( token, depth + 1, used_job_context, + resource_path, ) .await?; m.insert(a.clone(), v); @@ -3505,38 +3534,6 @@ struct GitRepositoryResource { branch: Option, } -/// Checks whether an IP address belongs to a private, loopback, link-local, or -/// otherwise reserved range that should not be reachable from git operations. -fn is_private_or_reserved_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - // RFC 1122 "this network": the whole /8, not just the - // unspecified address `is_unspecified()` matches — stacks that - // map 0.x.y.z onto the local host make `0.0.0.1` a bypass. - || v4.octets()[0] == 0 // 0.0.0.0/8 - || v4.is_broadcast() - // 100.64.0.0/10 (Carrier-grade NAT / CGNAT) - || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) - } - IpAddr::V6(v6) => { - let seg = v6.segments(); - v6.is_loopback() - || v6.is_unspecified() - // fc00::/7 (unique local address) — std has no stable is_unique_local() - || (seg[0] & 0xfe00) == 0xfc00 - // fe80::/10 (link-local) — std has no stable is_unicast_link_local() - || (seg[0] & 0xffc0) == 0xfe80 - // IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the inner v4 - || v6.to_ipv4_mapped().map_or(false, |v4| { - is_private_or_reserved_ip(&IpAddr::V4(v4)) - }) - } - } -} - /// Extracts the hostname from a git URL. /// /// Handles standard URLs (`https://host/path`, `ssh://user@host/path`) and @@ -3706,7 +3703,7 @@ async fn validate_git_url(url: &str, caller: GitRemoteCaller) -> Result<()> { // Check literal IP addresses if let Ok(ip) = host.parse::() { - if is_private_or_reserved_ip(&ip) { + if is_private_ip(&ip) { return Err(Error::BadRequest(format!( "Git URLs targeting private or reserved IP addresses are not allowed.{hint}" ))); @@ -3729,7 +3726,7 @@ async fn validate_git_url(url: &str, caller: GitRemoteCaller) -> Result<()> { ))); } for addr in addrs { - if is_private_or_reserved_ip(&addr.ip()) { + if is_private_ip(&addr.ip()) { return Err(Error::BadRequest(format!( "Git URL hostname resolves to a private or reserved IP address.{hint}" ))); @@ -4985,73 +4982,6 @@ mod tests { ); } - #[test] - fn test_is_private_or_reserved_ip() { - use std::net::IpAddr; - // Loopback - assert!(is_private_or_reserved_ip( - &"127.0.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"127.0.0.2".parse::().unwrap() - )); - // Private ranges - assert!(is_private_or_reserved_ip( - &"10.0.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"172.16.0.1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"192.168.1.1".parse::().unwrap() - )); - // Link-local / cloud metadata - assert!(is_private_or_reserved_ip( - &"169.254.169.254".parse::().unwrap() - )); - // CGNAT - assert!(is_private_or_reserved_ip( - &"100.64.0.1".parse::().unwrap() - )); - // "This network" 0.0.0.0/8, not just the unspecified address - assert!(is_private_or_reserved_ip( - &"0.0.0.0".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"0.0.0.1".parse::().unwrap() - )); - // IPv6 loopback - assert!(is_private_or_reserved_ip(&"::1".parse::().unwrap())); - // IPv6 unique local address (fc00::/7) - assert!(is_private_or_reserved_ip( - &"fd00::1".parse::().unwrap() - )); - assert!(is_private_or_reserved_ip( - &"fc00::1".parse::().unwrap() - )); - // IPv6 link-local (fe80::/10) - assert!(is_private_or_reserved_ip( - &"fe80::1".parse::().unwrap() - )); - // IPv4-mapped IPv6 - assert!(is_private_or_reserved_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); - // Public IPs should pass - assert!(!is_private_or_reserved_ip( - &"8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_or_reserved_ip( - &"140.82.121.4".parse::().unwrap() - )); - // Public IPv6 should pass - assert!(!is_private_or_reserved_ip( - &"2606:2800:220:1:248:1893:25c8:1946" - .parse::() - .unwrap() - )); - } - // A caller let through to private hosts must still hit the scheme check. #[tokio::test] async fn test_validate_git_url_blocks_file_scheme() { diff --git a/backend/windmill-test-utils/Cargo.toml b/backend/windmill-test-utils/Cargo.toml index 7f025800bc..3e409717bd 100644 --- a/backend/windmill-test-utils/Cargo.toml +++ b/backend/windmill-test-utils/Cargo.toml @@ -19,6 +19,7 @@ agent_worker_server = ["dep:windmill-api-agent-workers"] run_inline = ["windmill-api/run_inline"] duckdb = ["windmill-worker/duckdb"] postgres_trigger = ["windmill-api/postgres_trigger"] +http_trigger = ["windmill-api/http_trigger"] [dependencies] windmill-api = { workspace = true, default-features = false } diff --git a/backend/windmill-trigger-http/Cargo.toml b/backend/windmill-trigger-http/Cargo.toml index 9e81e1d0dd..d776a34a00 100644 --- a/backend/windmill-trigger-http/Cargo.toml +++ b/backend/windmill-trigger-http/Cargo.toml @@ -34,6 +34,7 @@ regex.workspace = true matchit.workspace = true hyper.workspace = true futures.workspace = true +rand.workspace = true # http_trigger_auth deps base64.workspace = true diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index 40bab102a5..e9dfbd405a 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -394,32 +394,85 @@ pub fn invalidate_routers() { HTTP_ROUTERS_INVALIDATIONS.fetch_add(1, Ordering::Relaxed); } +async fn invalidation_pending() -> bool { + HTTP_ROUTERS_INVALIDATIONS.load(Ordering::Relaxed) + != HTTP_ROUTERS_CACHE.read().await.invalidations +} + +async fn routers_loaded() -> bool { + HTTP_ROUTERS_CACHE.read().await.version != 0 +} + +/// `refresh_routers` for a process that has loaded its routers; a process that has not loads them +/// on its first `/r` request instead, so a change never makes it read `http_trigger`. Returns +/// whether the routers were rebuilt. A failure is marked for the refresh loop to retry. +pub async fn refresh_loaded_routers(db: &DB, force: bool) -> Result { + if !routers_loaded().await { + // A first load running concurrently may have read the rows before this change committed. + invalidate_routers(); + return Ok(false); + } + match refresh_routers(db, force).await { + Ok((rebuilt, _)) => Ok(rebuilt), + Err(err) => { + invalidate_routers(); + Err(err) + } + } +} + +const REFRESH_TICK: std::time::Duration = std::time::Duration::from_secs(60); +/// Trigger changes normally reach every process as a `notify_http_trigger_change` event, which +/// forces a rebuild. A process can skip one: the event poll moves past ids that committed out of +/// order, and a deleted or disabled trigger then stays routed on it until the next forced rebuild. +/// That rebuild has to be forced, since the skipped transaction took its version before the one +/// already cached, so the version gate cannot see it. +const FORCED_REBUILD_EVERY_TICKS: u32 = 5; + +/// `eager` loads the routers at startup and keeps retrying until they load. Otherwise they load +/// on the first `/r` request, and the loop only keeps them fresh from then on. pub async fn refresh_routers_loop( db: &DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, + eager: bool, ) -> () { - match refresh_routers(db, false).await { - Ok(_) => { - tracing::info!("Loaded HTTP routers"); - } - Err(err) => { - tracing::error!("Error loading HTTP routers: {err:#}"); - } - }; + if eager { + match refresh_routers(db, false).await { + Ok(_) => { + tracing::info!("Loaded HTTP routers"); + } + Err(err) => { + tracing::error!("Error loading HTTP routers: {err:#}"); + } + }; + } let db = db.clone(); + // Spread the forced rebuilds of processes started together. + let mut tick: u32 = rand::random_range(0..FORCED_REBUILD_EVERY_TICKS); tokio::spawn(async move { loop { tokio::select! { _ = killpill_rx.recv() => { break; } - _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { - match refresh_routers(&db, false).await { - Ok((true, _)) => { + _ = tokio::time::sleep(REFRESH_TICK) => { + tick = (tick + 1) % FORCED_REBUILD_EVERY_TICKS; + let loaded = routers_loaded().await; + let force = loaded && tick == 0; + if loaded { + if !force && !invalidation_pending().await { + continue; + } + } else if !eager { + continue; + } + match refresh_routers(&db, force).await { + Ok((true, _)) if !force => { tracing::info!("Refreshed HTTP routers"); } Err(err) => { tracing::error!("Error refreshing HTTP routers: {err:#}"); + invalidate_routers(); } _ => {} } diff --git a/backend/windmill-trigger-postgres/src/lib.rs b/backend/windmill-trigger-postgres/src/lib.rs index b92860662d..15918fca7b 100644 --- a/backend/windmill-trigger-postgres/src/lib.rs +++ b/backend/windmill-trigger-postgres/src/lib.rs @@ -236,6 +236,8 @@ pub struct Postgres { pub sslmode: String, #[serde(default, deserialize_with = "empty_as_none")] pub root_certificate_pem: Option, + #[serde(default, deserialize_with = "empty_as_none")] + pub options: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -349,6 +351,10 @@ pub async fn get_raw_postgres_connection( config.password(&database.password); } + if let Some(options) = &database.options { + config.options(options); + } + let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?; let client = if let Some(connector) = connector { let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?; diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 79890e3c6e..6dff8430b7 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -41,6 +41,11 @@ pub struct FlowStatus { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory_id: Option, + /// Set at push on a branch/loop sub-flow whose enclosing flow had no `flow_env`, own or + /// inherited, so the worker can skip the ancestor walk for it. `false` only means unknown: + /// the worker then walks the ancestors as usual. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub no_inherited_flow_env: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -568,6 +573,7 @@ impl FlowStatus { stream_job: None, chat_input_enabled: f.chat_input_enabled, memory_id: None, + no_inherited_flow_env: false, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 5190efe380..4e0c5340f5 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -519,6 +519,8 @@ pub enum JobPayload { FlowNode { id: FlowNodeId, path: String, + /// See [`crate::flow_status::FlowStatus::no_inherited_flow_env`]. + no_inherited_flow_env: bool, }, FlowScript { id: FlowNodeId, diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 7e775f34fe..682714292e 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -39,8 +39,8 @@ use windmill_common::{ worker::{to_raw_value, Connection}, }; use windmill_queue::{ - add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob, - MiniPulledJob, PushArgs, PushIsolationLevel, + add_completed_job, add_completed_job_error, check_tag_available_for_push, get_mini_pulled_job, + push, resolve_push_tag, MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, }; /// Shared collection of abort handles for spawned tool tasks. @@ -473,8 +473,7 @@ async fn execute_windmill_tool( )); } let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id); - // tool jobs are pushed with the parent agent job's tag and executed inline on the - // same worker, so a tag override on a nested agent tool does not apply here + // a nested agent always runs inline on this worker, under this agent's tag JobPayloadWithTag { payload: JobPayload::AIAgent { path }, tag: None, @@ -492,13 +491,6 @@ async fn execute_windmill_tool( } }; - let mut tx = ctx.db.begin().await?; - - let job_perms = - windmill_common::auth::get_job_perms(&mut *tx, &ctx.job.id, &ctx.job.workspace_id) - .await? - .map(|x| x.into()); - let (email, permissioned_as) = if let Some(on_behalf_of) = job_payload.on_behalf_of.as_ref() { (&on_behalf_of.email, on_behalf_of.permissioned_as.clone()) } else { @@ -508,6 +500,55 @@ async fn execute_windmill_tool( ) }; + let push_args = PushArgs { args: &tool_call_args, extra: None }; + + // A tool with a tag of its own naming another queue than this agent's must run on a worker + // that tag selects, and like any tag the caller chose it must pass the workspace's custom tag + // restrictions. It runs inline when this worker serves that queue: the agent holds this + // worker while it waits, so dispatching to a queue it serves could wait on itself. + let mut resolved_tool_tag = None; + if let Some(tag) = job_payload.tag.as_deref() { + resolved_tool_tag = resolve_push_tag(tag, &push_args, &ctx.job.workspace_id, ctx.db) + .await + .filter(|resolved| *resolved != ctx.job.tag); + } + let tool_tag = match (job_payload.tag.as_deref(), resolved_tool_tag.as_ref()) { + (Some(tag), Some(_)) => { + let is_super_admin = windmill_common::auth::is_super_admin_email(ctx.db, email).await?; + check_tag_available_for_push( + ctx.db, + &ctx.job.workspace_id, + tag, + &push_args, + is_super_admin, + None, + ) + .await + .map_err(|e| match e { + Error::BadRequest(msg) => Error::BadRequest(format!( + "tool '{}' cannot run on tag '{tag}': {msg}", + tool_call.function.name + )), + e => e, + })?; + Some(tag.to_string()) + } + _ => None, + }; + let run_inline = resolved_tool_tag.is_none_or(|resolved| { + windmill_common::worker::WORKER_CONFIG + .load() + .worker_tags + .contains(&resolved) + }); + + let mut tx = ctx.db.begin().await?; + + let job_perms = + windmill_common::auth::get_job_perms(&mut *tx, &ctx.job.id, &ctx.job.workspace_id) + .await? + .map(|x| x.into()); + let job_priority = tool_module.priority.or(ctx.job.priority); let tx = PushIsolationLevel::Transaction(tx); @@ -516,7 +557,7 @@ async fn execute_windmill_tool( tx, &ctx.job.workspace_id, job_payload.payload, - PushArgs { args: &tool_call_args, extra: None }, + push_args, &ctx.job.created_by, email, permissioned_as, @@ -532,12 +573,12 @@ async fn execute_windmill_tool( false, None, ctx.job.visible_to_owner, - Some(ctx.job.tag.clone()), + Some(tool_tag.unwrap_or_else(|| ctx.job.tag.clone())), job_payload.timeout, None, job_priority, job_perms.as_ref(), - true, + run_inline, None, None, None, @@ -546,12 +587,45 @@ async fn execute_windmill_tool( tx.commit().await?; + if !run_inline { + // Like an inline tool's failure, a lost wait reaches the model rather than failing the + // agent. + let (result, success) = + match wait_for_dispatched_tool_job(ctx.db, &uuid, &ctx.job.workspace_id).await { + Ok(outcome) => outcome, + Err(e) => ( + to_raw_value(&serde_json::json!({ "error": { "message": e.to_string() } })), + false, + ), + }; + return report_tool_result( + ctx, + tool_call, + tool_module, + job_id, + &result, + success, + is_ai_agent_tool && success, + messages, + final_events_str, + ) + .await; + } + let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?; let Some(tool_job) = tool_job else { return Err(Error::internal_err("Tool job not found".to_string())); }; + // The tool gets a token of its own, as it would on any worker: the agent's token would + // identify the tool as the agent job (its OIDC path and job id). + let tool_client = AuthedClient::new( + ctx.base_internal_url.to_string(), + tool_job.workspace_id.clone(), + windmill_queue::create_token(ctx.db, &tool_job, None).await, + None, + ); let tool_job = Arc::new(tool_job); let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1); @@ -564,7 +638,7 @@ async fn execute_windmill_tool( // Clone everything needed for the spawned task let tool_job_spawn = tool_job.clone(); let conn_spawn = ctx.conn.clone(); - let client_spawn = ctx.client.clone(); + let client_spawn = tool_client; let hostname_spawn = ctx.hostname.to_string(); let worker_name_spawn = ctx.worker_name.to_string(); let worker_dir_spawn = ctx.worker_dir.to_string(); @@ -793,12 +867,93 @@ async fn handle_tool_execution_success( )); }; + report_tool_result( + ctx, + tool_call, + tool_module, + job_id, + &result, + success, + is_ai_agent_tool && job_success, + messages, + final_events_str, + ) + .await +} + +/// Wait for a tool job another worker runs; returns its result and whether it succeeded. +/// Canceling the agent cancels this job along with it, as a child of the agent's job. +async fn wait_for_dispatched_tool_job( + db: &DB, + id: &Uuid, + w_id: &str, +) -> Result<(Box, bool), Error> { + const MAX_CONSECUTIVE_POLL_ERRORS: u32 = 10; + let mut interval = std::time::Duration::from_millis(50); + let mut poll_errors = 0; + loop { + // One statement reads both tables from one snapshot, so a job completing between two + // reads cannot look like one that vanished. + let state = match sqlx::query!( + "SELECT c.id IS NOT NULL AS \"completed!\", + c.result AS \"result: sqlx::types::Json>\", + c.status = 'success' AS \"success\", + EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\" + FROM (SELECT 1) one + LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2", + id, + w_id + ) + .fetch_one(db) + .await + { + Ok(state) => { + poll_errors = 0; + state + } + Err(e) if poll_errors < MAX_CONSECUTIVE_POLL_ERRORS => { + poll_errors += 1; + tracing::warn!("polling tool job {id} failed, retrying: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + Err(e) => return Err(e.into()), + }; + if state.completed { + let result = state + .result + .map(|r| r.0) + .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null)); + return Ok((result, state.success.unwrap_or(false))); + } + if !state.queued { + return Err(Error::internal_err(format!( + "tool job {id} is neither queued nor completed" + ))); + } + tokio::time::sleep(interval).await; + interval = std::cmp::min(interval * 2, std::time::Duration::from_secs(1)); + } +} + +/// Hand a finished tool job's result to the model, the stream, the flow status and the chat. +async fn report_tool_result( + ctx: &mut ToolExecutionContext<'_>, + tool_call: &OpenAIToolCall, + tool_module: &windmill_common::flows::FlowModule, + job_id: Uuid, + result: &RawValue, + success: bool, + is_ai_agent_output: bool, + messages: &mut Vec, + final_events_str: &mut String, +) -> Result<(), Error> { // A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries // the child's entire message history, stream log and token usage. Feeding that back would // grow the caller's context by the child's full transcript on every call, so the caller only // sees `output`. The envelope stays intact in the tool job's completed row. - let tool_result = if is_ai_agent_tool && job_success { - extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string()) + let tool_result = if is_ai_agent_output { + extract_ai_agent_output(result).unwrap_or_else(|| result.get().to_string()) } else { result.get().to_string() }; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 3c3a26875b..3d7fce4d07 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1049,6 +1049,11 @@ pub async fn handle_ai_agent_job( GracefulPollOutcome::Ok(result) => Ok(result), GracefulPollOutcome::Timeout(ms) => { tracing::error!("AI agent timeout after {}s", ms / 1000); + // A tool dispatched to another worker group would otherwise still run later, after + // the agent that wanted its result is gone. + let reason = format!("parent AI agent {} timed out", job.id); + let cb = CanceledBy { username: None, reason: Some(reason) }; + cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, Some(cb)).await; Err(Error::ExecutionErr(format!( "AI agent timeout after (>{}s)", ms / 1000 @@ -1419,6 +1424,11 @@ pub async fn run_agent( .map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS)) .unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS); + // Held across iterations so an OIDC role is assumed once for the job and + // re-assumed only near expiry, rather than on every model turn. + #[cfg(feature = "bedrock")] + let mut bedrock_assumed_role: Option = None; + // Main agent loop for i in 0..max_iterations { // Check if parent was canceled — stop iterating but let current tool calls finish @@ -1438,6 +1448,27 @@ pub async fn run_agent( .region .as_deref() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); + // An OIDC role resolves to ordinary IAM keys, so from here the + // call is identical to the explicit-keys mode. + let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials( + &credentials, + &mut bedrock_assumed_role, + client, + &job.id, + ) + .await?; + let (access_key_id, secret_access_key, session_token) = match assumed { + Some(assumed) => ( + Some(assumed.access_key_id.as_str()), + Some(assumed.secret_access_key.as_str()), + Some(assumed.session_token.as_str()), + ), + None => ( + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.aws_session_token.as_deref(), + ), + }; // Use Bedrock SDK via dedicated query builder windmill_ai::providers::bedrock::BedrockQueryBuilder::default() .execute_request( @@ -1453,9 +1484,9 @@ pub async fn run_agent( client, &job.workspace_id, structured_output_tool_name.as_deref(), - credentials.aws_access_key_id.as_deref(), - credentials.aws_secret_access_key.as_deref(), - credentials.aws_session_token.as_deref(), + access_key_id, + secret_access_key, + session_token, ) .await? } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index aecdb738b3..943703472f 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -2692,7 +2692,7 @@ fn datatable_secret_name(alias: &str) -> String { } /// ATTACH a datatable's postgres database through a DuckDB TEMPORARY SECRET holding -/// the connection parameters; only sslmode rides in the ATTACH string. +/// the connection parameters; only sslmode and options ride in the ATTACH string. fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result> { let res: PgDatabase = serde_json::from_value(db_resource)?; // Escape single quotes: each field is embedded in a single-quoted DuckDB literal, @@ -2705,6 +2705,15 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result "require", _ => "prefer", }; + // Nor an options parameter. The value is quoted for libpq's keyword/value syntax first, + // then escaped for the DuckDB literal around it. + let options = match res.non_empty_options() { + Some(o) => esc(&format!( + " options='{}'", + o.replace('\\', "\\\\").replace('\'', "\\'") + )), + None => String::new(), + }; let secret_name = datatable_secret_name(alias_name); Ok(vec![ "INSTALL postgres;".to_string(), @@ -2717,7 +2726,7 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result' (TYPE postgres, SECRET …)` would reach a database nobody @@ -3976,6 +3985,19 @@ mod tests { } } + #[test] + fn test_pg_secret_attach_statements_options() { + let db_resource = json!({ "host": "h", "dbname": "d", "options": r"-c search_path='a\b'" }); + let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let secret_name = datatable_secret_name("dt"); + assert_eq!( + stmts[3], + format!( + r"ATTACH 'sslmode=prefer options=''-c search_path=\''a\\b\''''' AS dt (TYPE postgres, SECRET {secret_name});" + ) + ); + } + #[test] fn test_datatable_secret_name_sanitizes_and_disambiguates() { let a = datatable_secret_name("a.b"); diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index e1b365bcf1..3c9be1f4c5 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -41,7 +41,7 @@ use windmill_common::job_metrics; use tokio::{ io::{AsyncBufReadExt, BufReader}, sync::{broadcast, watch}, - time::{interval, sleep, Instant, MissedTickBehavior}, + time::{interval_at, sleep, Instant, MissedTickBehavior}, }; use futures::{ @@ -955,8 +955,13 @@ where let update_job_interval = Duration::from_millis(500); let conn = conn.clone(); - let mut interval = interval(update_job_interval); + // No tick at t=0: a job that finishes within the first interval issues none of the + // ping/metric statements below. Cancels are picked up by the first job ping (500 ms in, + // 2 s on agent workers). Memory is still sampled at start, without SQL: it is the only + // `mem_peak` reading a short job gets, and the completed job row reports it. + let mut interval = interval_at(Instant::now() + update_job_interval, update_job_interval); interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + *mem_peak = (*mem_peak).max(get_mem.next().await.unwrap_or(0)); let mut i = 0; @@ -993,12 +998,12 @@ where } - let update_job_row = i == 2 || (!*SLOW_LOGS && (i < 20 || (i < 120 && i % 5 == 0) || i % 10 == 0)) || i % 20 == 0; + let update_job_row = i == 1 || (!*SLOW_LOGS && (i < 20 || (i < 120 && i % 5 == 0) || i % 10 == 0)) || i % 20 == 0; if update_job_row && job_id != Uuid::nil() { if let Connection::Sql(ref db) = conn { // Only track memory when it's non-zero (avoids storing all-zero timeseries for jobs that don't report memory) if current_mem > 0 { - // Register on first non-zero reading (deferred from i==2 to avoid metric for jobs with no memory reporting) + // Register on first non-zero reading, so jobs with no memory reporting get no metric if memory_metric_id.is_err() { memory_metric_id = job_metrics::register_metric_for_job( &db, diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index c188792a5e..45d76c74e8 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -489,6 +489,16 @@ pub fn is_wac_v2(lang: Option, content: &str) -> bool { } } +/// Whether a job in `lang` can be a workflow-as-code v2 entrypoint at all, before its +/// content is known. Must cover every language `is_wac_v2` accepts: a missing one +/// repeats the job header in the logs on every replay. +pub fn lang_supports_wac_v2(lang: Option) -> bool { + matches!( + lang, + Some(ScriptLang::Bun) | Some(ScriptLang::Bunnative) | Some(ScriptLang::Python3) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -512,5 +522,12 @@ export default workflow(async function main(x: number) { return x; });"#; Some(ScriptLang::Bun), "export async function main() {}" )); + + for lang in [ScriptLang::Bun, ScriptLang::Bunnative, ScriptLang::Python3] { + assert!(lang_supports_wac_v2(Some(lang))); + } + assert!(!lang_supports_wac_v2(Some(ScriptLang::Deno))); + assert!(!lang_supports_wac_v2(Some(ScriptLang::Bash))); + assert!(!lang_supports_wac_v2(None)); } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 55a698a9dd..a2ddac713e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -4772,13 +4772,17 @@ pub async fn handle_queued_job( }; // Skip verbose job header for WAC v2 replays (checkpoint has completed steps) - let is_wac_replay = if let Connection::Sql(db) = conn { - crate::wac_executor::load_checkpoint(db, &job.id) - .await - .map(|c| !c.completed_steps.is_empty()) - .unwrap_or(false) - } else { - false + let is_wac_replay = match conn { + Connection::Sql(db) + if crate::wac_executor::lang_supports_wac_v2(job.script_lang) + && !job.kind.is_dependency() => + { + crate::wac_executor::load_checkpoint(db, &job.id) + .await + .map(|c| !c.completed_steps.is_empty()) + .unwrap_or(false) + } + _ => false, }; if !is_wac_replay { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0ab4e2871c..a3257ace36 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -44,12 +44,13 @@ use windmill_common::flow_status::{ }; use windmill_common::flows::{add_virtual_items_if_necessary, Branch, FlowNodeId, StopAfterIf}; use windmill_common::jobs::{ - script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE, + script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, TriggerKindLabel, + ENTRYPOINT_OVERRIDE, }; use windmill_common::runnable_settings::{ ConcurrencySettingsWithCustom, DebouncingSettings, RunnableSettingsTrait, }; -use windmill_common::scripts::{ScriptHash, ScriptRunnableSettingsInline}; +use windmill_common::scripts::{ScriptHash, ScriptLang, ScriptRunnableSettingsInline}; use windmill_common::utils::WarnAfterExt; use windmill_common::worker::{error_to_value, to_raw_value, Connection}; use windmill_common::{ @@ -537,15 +538,22 @@ pub async fn update_flow_status_after_job_completion_internal( if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { Some(cached) } else { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) - .await - .map(|(env, is_cacheable)| { - let arc = Arc::new(env); - if is_cacheable { - RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); - } - arc - }) + resolve_flow_env_for_status_update( + db, + client, + flow, + w_id, + flow_value, + old_status.no_inherited_flow_env, + ) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) } } else { None @@ -1366,36 +1374,52 @@ pub async fn update_flow_status_after_job_completion_internal( } let step_counter = if inc_step_counter { - sqlx::query!( - "UPDATE v2_job_status - SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1) - WHERE id = $2", - json!(old_status.step + 1), - flow - ) - .execute(&mut *tx) - .await - .map_err(|e| { - Error::internal_err(format!("error while setting flow index for {flow}: {e:#}")) - })?; old_status.step + 1 } else { old_status.step }; - // tracing::error!( - // "step_counter: {:?} {} {inc_step_counter} {flow}", - // step_counter, - // old_status.step, - // ); - // panic!("stop"); - /* is_last_step is true when the step_counter (the next step index) is an invalid index */ let is_last_step = usize::try_from(step_counter) .map(|i| !(..old_status.modules.len()).contains(&i)) .unwrap_or(true); - if let Some(new_status) = new_status.as_ref() { + let nresult = if let Some(nresult) = nresult { + // can be some either with early stop error or with the flow jobs results (was fetched to evaluate stop_early_after_all_iters but evaluated to false) + nresult + } else { + match &new_status { + Some(FlowStatusModule::Success { flow_jobs: Some(jobs), .. }) + | Some(FlowStatusModule::Failure { flow_jobs: Some(jobs), .. }) => { + Arc::new(retrieve_flow_jobs_results(&mut *tx, w_id, jobs).await?) + } + _ => result.clone(), + } + }; + + let remove_retry = old_status.retry.fail_count > 0 + && matches!(&new_status, Some(FlowStatusModule::Success { .. })); + + let special_step_status = new_status + .as_ref() + .filter(|_| is_failure_step || module_step.is_preprocessor_step()); + + let flow_job = if let Some(new_status) = special_step_status { + if inc_step_counter { + sqlx::query!( + "UPDATE v2_job_status + SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1) + WHERE id = $2", + json!(step_counter), + flow + ) + .execute(&mut *tx) + .await + .map_err(|e| { + Error::internal_err(format!("error while setting flow index for {flow}: {e:#}")) + })?; + } + if is_failure_step { let parent_module = sqlx::query_scalar!( "SELECT flow_status->'failure_module'->>'parent_module' FROM v2_job_status WHERE id = $1", @@ -1425,7 +1449,7 @@ pub async fn update_flow_status_after_job_completion_internal( "error while setting flow status in failure step: {e:#}" )) })?; - } else if module_step.is_preprocessor_step() { + } else { sqlx::query!( "UPDATE v2_job_status SET flow_status = JSONB_SET(flow_status, ARRAY['preprocessor_module'], $1) @@ -1440,69 +1464,71 @@ pub async fn update_flow_status_after_job_completion_internal( "error while setting flow status in preprocessing step: {e:#}" )) })?; - } else { + } + + if remove_retry { sqlx::query!( "UPDATE v2_job_status - SET flow_status = JSONB_SET(flow_status, ARRAY['modules', $1::TEXT], $2) - WHERE id = $3", - old_status.step.to_string(), - json!(new_status), + SET flow_status = flow_status - 'retry' + WHERE id = $1", flow ) .execute(&mut *tx) .await - .map_err(|e| { - Error::internal_err(format!("error while setting new flow status: {e:#}")) - })?; - - if let Some(job_result) = new_status.job_result() { - sqlx::query!( - "UPDATE v2_job_status - SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2) - WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $3), $3) = id", - new_status.id(), - json!(job_result), - flow - ) - .execute(&mut *tx) - .await.map_err(|e| { - Error::internal_err(format!( - "error while setting leaf jobs: {e:#}" - )) - })?; - } + .context("remove flow status retry")?; } - } - let nresult = if let Some(nresult) = nresult { - // can be some either with early stop error or with the flow jobs results (was fetched to evaluate stop_early_after_all_iters but evaluated to false) - nresult + get_mini_pulled_job(&mut *tx, &flow).await? } else { - match &new_status { - Some(FlowStatusModule::Success { flow_jobs: Some(jobs), .. }) - | Some(FlowStatusModule::Failure { flow_jobs: Some(jobs), .. }) => { - Arc::new(retrieve_flow_jobs_results(&mut *tx, w_id, jobs).await?) + let module_status = new_status + .as_ref() + .map(|s| (old_status.step.to_string(), json!(s))); + let leaf_job = new_status + .as_ref() + .and_then(|s| s.job_result().map(|r| (s.id(), json!(r)))); + + let flow_job = if inc_step_counter || module_status.is_some() || remove_retry { + match advance_flow_status( + &mut tx, + flow, + inc_step_counter.then_some(step_counter), + module_status, + leaf_job.as_ref(), + remove_retry, + ) + .await? + { + Some(flow_job) => Some(flow_job), + None => get_mini_pulled_job(&mut *tx, &flow).await?, } - _ => result.clone(), + } else { + get_mini_pulled_job(&mut *tx, &flow).await? + }; + + // The leaf jobs of a subflow are kept on its innermost root's row, which the + // statement above does not touch. + let innermost_root = flow_job + .as_ref() + .and_then(|j| j.flow_innermost_root_job) + .filter(|root| *root != flow); + if let (Some((leaf_id, leaf_result)), Some(root)) = (leaf_job, innermost_root) { + sqlx::query!( + "UPDATE v2_job_status + SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2) + WHERE id = $3", + leaf_id, + leaf_result, + root + ) + .execute(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("error while setting leaf jobs: {e:#}")))?; } + + flow_job }; - if old_status.retry.fail_count > 0 - && matches!(&new_status, Some(FlowStatusModule::Success { .. })) - { - sqlx::query!( - "UPDATE v2_job_status - SET flow_status = flow_status - 'retry' - WHERE id = $1", - flow - ) - .execute(&mut *tx) - .await - .context("remove flow status retry")?; - } - - let flow_job = get_mini_pulled_job(&mut *tx, &flow) - .await? + let flow_job = flow_job .ok_or_else(|| Error::internal_err(format!("requiring flow to be in the queue")))?; tx.commit().await?; @@ -2297,6 +2323,100 @@ async fn set_success_and_duration_in_flow_job_success<'c>( Ok(()) } +/// Applies a step's edits to the flow's `v2_job_status` row and reads the flow back, in one +/// statement: every UPDATE of that row writes a new row version carrying the whole +/// `flow_status`, so the edits must not be split. `leaf_job` is only written here when the flow +/// is its own innermost root; otherwise it belongs on the root's row and the caller writes it +/// there. `Ok(None)` means nothing was written because the flow has no queued `v2_job_status` +/// row; the caller then reads the flow with `get_mini_pulled_job`, whose LEFT JOIN still returns +/// a queued flow that lacks a status row. +async fn advance_flow_status( + tx: &mut Transaction<'_, Postgres>, + flow: Uuid, + step: Option, + module_status: Option<(String, Value)>, + leaf_job: Option<&(String, Value)>, + remove_retry: bool, +) -> error::Result> { + // An edit that does not apply gets an empty path, which JSONB_SET treats as a no-op. Its + // value must stay JSON `null`, never SQL NULL: JSONB_SET is strict and would null the whole + // `flow_status`. Each edit stays a JSONB_SET on its own path so that a malformed + // `flow_status` fails exactly as the equivalent separate UPDATEs would. + let (step_path, step) = match step { + Some(step) => (vec!["step"], json!(step)), + None => (vec![], Value::Null), + }; + let (module_path, module_status) = match &module_status { + Some((index, status)) => (vec!["modules", index.as_str()], status), + None => (vec![], &Value::Null), + }; + let (leaf_id, leaf_result) = leaf_job.map(|(id, r)| (id.as_str(), r)).unzip(); + let removed_keys: &[&str] = if remove_retry { &["retry"] } else { &[] }; + + sqlx::query_as!( + MiniPulledJob, + "UPDATE v2_job_status SET + flow_status = JSONB_SET( + JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3), + $4::TEXT[], $5 + ) - $6::TEXT[], + flow_leaf_jobs = CASE + WHEN $7::TEXT IS NULL + OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id + THEN v2_job_status.flow_leaf_jobs + ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END + FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id + WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1 + RETURNING + v2_job_queue.workspace_id, + v2_job_queue.id, + v2_job.args as \"args: sqlx::types::Json>>\", + v2_job.parent_job, + v2_job.created_by, + v2_job_queue.started_at, + v2_job_queue.runnable_settings_handle, + v2_job_queue.scheduled_for, + v2_job.runnable_path, + v2_job.kind as \"kind: JobKind\", + v2_job.runnable_id as \"runnable_id: ScriptHash\", + v2_job_queue.canceled_reason, + v2_job_queue.canceled_by, + v2_job.permissioned_as, + v2_job.permissioned_as_email, + v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\", + v2_job.tag, + v2_job.script_lang as \"script_lang: ScriptLang\", + v2_job.same_worker, + v2_job.pre_run_error, + v2_job.concurrent_limit, + v2_job.concurrency_time_window_s, + v2_job.flow_innermost_root_job, + v2_job.root_job, + v2_job.timeout, + v2_job.flow_step_id, + v2_job.cache_ttl, + v2_job_queue.cache_ignore_s3_path, + v2_job_queue.priority, + v2_job.preprocessed, + v2_job.script_entrypoint_override, + v2_job.trigger, + v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\", + v2_job.visible_to_owner, + NULL as permissioned_as_end_user_email", + flow, + &step_path as &[&str], + step, + &module_path as &[&str], + module_status, + removed_keys as &[&str], + leaf_id, + leaf_result, + ) + .fetch_optional(&mut **tx) + .await + .map_err(|e| Error::internal_err(format!("error while setting new flow status: {e:#}"))) +} + async fn retrieve_flow_jobs_results<'c>( e: impl sqlx::PgExecutor<'c>, w_id: &str, @@ -2521,6 +2641,7 @@ async fn resolve_flow_env_for_status_update( flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, + no_inherited_flow_env: bool, ) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` @@ -2529,6 +2650,8 @@ async fn resolve_flow_env_for_status_update( let (env, mini): (HashMap>, Option) = if let Some(ref e) = flow_value.flow_env { (e.clone(), None) + } else if no_inherited_flow_env { + return None; } else { let mini = match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, @@ -2542,7 +2665,13 @@ async fn resolve_flow_env_for_status_update( // No own flow_env and no parent to inherit from — nothing to resolve. return None; } - let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + let env = match fetch_root_flow_env(db, flow_job_id, workspace_id).await { + Ok(env) => env?, + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + return None; + } + }; (env, Some(mini)) }; if env.is_empty() { @@ -2608,7 +2737,7 @@ async fn fetch_root_flow_env( db: &DB, flow_job_id: Uuid, workspace_id: &str, -) -> Option>> { +) -> Result>>, sqlx::Error> { sqlx::query_scalar!( r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 @@ -2645,10 +2774,7 @@ async fn fetch_root_flow_env( ) .fetch_optional(db) .await - .ok() - .flatten() - .flatten() - .map(|json| json.0) + .map(|row| row.flatten().map(|json| json.0)) } struct FailureContext { @@ -2793,18 +2919,31 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + let status = flow_job + .parse_flow_status() + .with_context(|| "Unable to parse flow status")?; + // Sub-flows spawned by `payload_from_modules` for branches/loops don't // carry the parent's `flow_env` in their own FlowValue. Fall back to the // nearest enclosing scope's `flow_env` so predicates like `skip_if`, // `stop_after_if`, and branch conditions see the same env as input // transforms. - let inherited_env: Option>> = - if flow.flow_env.is_none() && flow_job.parent_job.is_some() { - fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await - } else { - None - }; + let (inherited_env, inherited_env_known) = if flow.flow_env.is_none() + && flow_job.parent_job.is_some() + && !status.no_inherited_flow_env + { + match fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await { + Ok(env) => (env, true), + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + (None, false) + } + } + } else { + (None, true) + }; let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + let no_flow_env = inherited_env_known && env_source.is_none_or(|e| e.is_empty()); // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue @@ -2850,10 +2989,6 @@ pub async fn handle_flow( } } - let status = flow_job - .parse_flow_status() - .with_context(|| "Unable to parse flow status")?; - let schedule_path = flow_job.schedule_path(); if !flow_job.is_flow_step() && status.retry.fail_count == 0 @@ -2997,6 +3132,7 @@ pub async fn handle_flow( status, flow, flow_env, + no_flow_env, db, client, last_result.clone(), @@ -3240,6 +3376,8 @@ async fn push_next_flow_job( mut status: FlowStatus, flow: &FlowValue, flow_env: Option<&HashMap>>, + // Neither this flow nor any ancestor defines `flow_env`. + no_flow_env: bool, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -4229,7 +4367,14 @@ async fn push_next_flow_job( let mut tx = db.begin().warn_after_seconds(3).await?; let nargs = args.as_ref(); - for (i, payload_tag) in job_payloads.into_iter().enumerate() { + for (i, mut payload_tag) in job_payloads.into_iter().enumerate() { + // A `FlowNode` here is a branch/loop sub-flow from `payload_from_modules`: it never defines + // its own `flow_env`, so it inherits exactly this flow's. The mark lives in the child's + // status, not its definition: a `RawFlow` child's stored definition is replayed by nested + // restarts, whose root may have gained a `flow_env` since. + if let JobPayload::FlowNode { no_inherited_flow_env, .. } = &mut payload_tag.payload { + *no_inherited_flow_env = no_flow_env; + } if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the @@ -5218,7 +5363,7 @@ fn payload_from_modules<'a>( } if let Some(id) = modules_node { - return Some(JobPayload::FlowNode { id, path: path() }); + return Some(JobPayload::FlowNode { id, path: path(), no_inherited_flow_env: false }); } add_virtual_items_if_necessary(&mut modules); @@ -6153,7 +6298,7 @@ async fn payload_from_simple_module( pub fn raw_script_to_payload( path: String, content: String, - language: windmill_common::scripts::ScriptLang, + language: ScriptLang, lock: Option, concurrency_settings: ConcurrencySettingsWithCustom, module: &FlowModule, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 1e6cfdd474..3edd3c5318 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.817.0"; +export const VERSION = "v1.819.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index ff470d4ffe..d74b6217ac 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.817.0", + "version": "1.819.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.817.0", + "version": "1.819.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 5655adc64e..7de263a3b9 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.817.0", + "version": "1.819.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/TESTING.md b/cli/TESTING.md index df0311677d..7bf87cb350 100644 --- a/cli/TESTING.md +++ b/cli/TESTING.md @@ -53,9 +53,17 @@ in-process suite imports.** Check with `grep -rl "" test/` before r for one. A suite that drives the CLI through a spawned process is out of reach of a module mock and doesn't count. -`raw_app_push_policy_unit.test.ts` is the worked example: it stubs `gen/services.gen.ts`, -which passes the rule because nothing else in `test/` imports the three API functions it -replaces, and deliberately does not stub `bundle.ts`, which failed it. +The API client `gen/services.gen.ts` is the exception, and is stubbed only through +`mockServices` in `test/mock_services.ts`. Bun fixes a mocked module's export names at +the first `mock.module` call of the run, so a stub that replaced the module with just the +functions its suite needed left every other name undefined for the rest of the run, even +for suites that stubbed that name themselves. `test/mock_services.ts` therefore calls +`mock.module` exactly once, with every real export behind a dispatcher; `mockServices` +only swaps which stubs the dispatcher routes to, so it doesn't rely on the unreliable +`afterAll` re-mock described above. + +`raw_app_push_policy_unit.test.ts` deliberately does not stub `bundle.ts`, which failed +the rule. ## AI Benchmark Caveats diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 9728b6edd7..1d14d4f4e2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.817.0"; +export const VERSION = "1.819.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 6a9358b468..f065877244 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -408,7 +408,18 @@ export async function pushWorkspaceSettings( }); } - if (!deepEqual(localSettings.operator_settings, settings.operator_settings)) { + // The endpoint keeps the stored value of a write right the file omits, so an older file must + // not count as differing on those keys, or every push would re-post it. + const localOperatorSettings = localSettings.operator_settings && { + ...localSettings.operator_settings, + }; + for (const key of ["manage_schedules", "manage_triggers"] as const) { + const remote = settings.operator_settings?.[key]; + if (localOperatorSettings && localOperatorSettings[key] === undefined && remote !== undefined) { + localOperatorSettings[key] = remote; + } + } + if (localOperatorSettings && !deepEqual(localOperatorSettings, settings.operator_settings)) { log.debug(`Updating operator settings...`); await wmill.updateOperatorSettings({ workspace, diff --git a/cli/test/deploy_labels_unit.test.ts b/cli/test/deploy_labels_unit.test.ts new file mode 100644 index 0000000000..2686eb8cc6 --- /dev/null +++ b/cli/test/deploy_labels_unit.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { deployItem } from "../windmill-utils-internal/src/deploy.ts"; + +// Variables, resources and folders are deployed from a body built field by field rather than +// spread from the source item, so any field left out of it is silently dropped in the target. +test("deployItem: carries labels for variables, resources and folders", async () => { + const labels = ["prod", "billing"]; + const writes: [string, any][] = []; + const provider = (exists: boolean) => + ({ + existsVariable: async () => exists, + existsResource: async () => exists, + existsFolder: async () => exists, + getVariable: async () => ({ value: "v", is_secret: false, labels }), + getResource: async () => ({ + value: {}, + resource_type: "postgresql", + labels, + }), + getFolder: async () => ({ + name: "x", + owners: [], + extra_perms: {}, + labels, + }), + ...Object.fromEntries( + [ + "createVariable", + "updateVariable", + "createResource", + "updateResource", + "createFolder", + "updateFolder", + ].map((fn) => [ + fn, + async (p: any) => void writes.push([fn, p.requestBody]), + ]), + ), + }) as any; + + for (const exists of [false, true]) { + for (const [kind, path] of [ + ["variable", "f/x/v"], + ["resource", "f/x/r"], + ["folder", "f/x"], + ]) { + const result = await deployItem( + provider(exists), + kind as any, + path, + "src", + "dst", + ); + expect(result).toEqual({ success: true }); + } + } + + expect(writes.map(([fn]) => fn).sort()).toEqual([ + "createFolder", + "createResource", + "createVariable", + "updateFolder", + "updateResource", + "updateVariable", + ]); + for (const [, body] of writes) expect(body.labels).toEqual(labels); +}); + +test("deployItem: clears a folder's labels when the source has none", async () => { + let body: any; + await deployItem( + { + existsFolder: async () => true, + // A folder with cleared labels reads back without the field at all. + getFolder: async () => ({ name: "x", owners: [], extra_perms: {} }), + updateFolder: async (p: any) => void (body = p.requestBody), + } as any, + "folder", + "f/x", + "src", + "dst", + ); + expect(body.labels).toEqual([]); +}); diff --git a/cli/test/instance_configs_unit.test.ts b/cli/test/instance_configs_unit.test.ts index c8ba15c5dc..595f31aa6a 100644 --- a/cli/test/instance_configs_unit.test.ts +++ b/cli/test/instance_configs_unit.test.ts @@ -8,7 +8,8 @@ * - pushInstanceConfigs skips unchanged configs */ -import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import { mockServices } from "./mock_services.ts"; import { writeFile, readFile, mkdir, rm } from "node:fs/promises"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -21,7 +22,7 @@ let updateConfigCalls: { name: string; requestBody: any }[] = []; let deleteConfigCalls: { name: string }[] = []; // Mock the wmill module before importing settings.ts -mock.module("../gen/services.gen.ts", () => ({ +mockServices({ listWorkerGroups: async () => listWorkerGroupsResult, updateConfig: async (args: { name: string; requestBody: any }) => { updateConfigCalls.push(args); @@ -32,7 +33,7 @@ mock.module("../gen/services.gen.ts", () => ({ listConfigs: async () => { throw new Error("listConfigs should not be called"); }, -})); +}); import { pullInstanceConfigs, diff --git a/cli/test/mock_services.ts b/cli/test/mock_services.ts new file mode 100644 index 0000000000..3e61af1979 --- /dev/null +++ b/cli/test/mock_services.ts @@ -0,0 +1,35 @@ +import { afterAll, mock } from "bun:test"; + +// Every in-process suite that stubs the API client goes through here. Bun +// fixes a mocked module's export names at the first `mock.module` call of the +// run, so a stub missing a name leaves it undefined for every later suite, even +// ones that stub it themselves. The module is therefore mocked once, with every +// real export behind a dispatcher: a suite swaps its stubs in and out of +// `active`, and never depends on a later `mock.module` call taking effect. +const real = { ...(await import("../gen/services.gen.ts")) } as Record< + string, + unknown +>; +let active: Record = {}; + +const dispatched: Record = {}; +for (const [name, value] of Object.entries(real)) { + dispatched[name] = + typeof value === "function" + ? (...args: unknown[]) => + ((active[name] ?? value) as (...a: unknown[]) => unknown)(...args) + : value; +} +mock.module("../gen/services.gen.ts", () => dispatched); + +export function mockServices(stubs: Record): void { + for (const name of Object.keys(stubs)) { + if (!(name in real)) { + throw new Error(`mockServices: services.gen.ts has no export ${name}`); + } + } + active = stubs; + afterAll(() => { + active = {}; + }); +} diff --git a/cli/test/push_workspace_key_unit.test.ts b/cli/test/push_workspace_key_unit.test.ts index 6d8950cbec..aa5d094fd7 100644 --- a/cli/test/push_workspace_key_unit.test.ts +++ b/cli/test/push_workspace_key_unit.test.ts @@ -13,7 +13,8 @@ * - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var */ -import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import { mockServices } from "./mock_services.ts"; // Track calls to mocked wmill functions let remoteKey = ""; @@ -23,7 +24,7 @@ let setEncryptionKeyCalls: { }[] = []; // Mock the wmill module before importing settings.ts -mock.module("../gen/services.gen.ts", () => ({ +mockServices({ getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({ key: remoteKey, }), @@ -33,7 +34,7 @@ mock.module("../gen/services.gen.ts", () => ({ }) => { setEncryptionKeyCalls.push(args); }, -})); +}); import { pushWorkspaceKey } from "../src/core/settings.ts"; diff --git a/cli/test/push_workspace_settings_auto_invite_unit.test.ts b/cli/test/push_workspace_settings_auto_invite_unit.test.ts index b04d895056..f4564c25b2 100644 --- a/cli/test/push_workspace_settings_auto_invite_unit.test.ts +++ b/cli/test/push_workspace_settings_auto_invite_unit.test.ts @@ -4,7 +4,8 @@ * instance groups that settings.yaml does not declare. */ -import { expect, test, describe, beforeEach, mock } from "bun:test"; +import { expect, test, describe, beforeEach } from "bun:test"; +import { mockServices } from "./mock_services.ts"; let editAutoInviteCalls: unknown[] = []; let editInstanceGroupsCalls: unknown[] = []; @@ -17,10 +18,9 @@ const remoteAutoInvite = { instance_groups_roles: { eng: "developer" }, }; -// Every wmill.* call reachable from pushWorkspaceSettings is stubbed: bun shares one -// mocked module across test files, and names missing from whichever mock loads first -// stay missing for the others. -mock.module("../gen/services.gen.ts", () => ({ +// Every wmill.* call reachable from pushWorkspaceSettings is stubbed so the +// function runs without a backend. +mockServices({ getSettings: async () => ({ auto_invite: remoteAutoInvite }), getWorkspaceName: async () => "phoenix", changeWorkspaceName: async () => {}, @@ -45,7 +45,7 @@ mock.module("../gen/services.gen.ts", () => ({ editSlackCommand: async () => {}, setWorkspaceSlackOauthConfig: async () => {}, deleteWorkspaceSlackOauthConfig: async () => {}, -})); +}); const { pushWorkspaceSettings } = await import("../src/core/settings.ts"); diff --git a/cli/test/push_workspace_settings_identity_unit.test.ts b/cli/test/push_workspace_settings_identity_unit.test.ts index 5dcf9815ef..d11b5efac4 100644 --- a/cli/test/push_workspace_settings_identity_unit.test.ts +++ b/cli/test/push_workspace_settings_identity_unit.test.ts @@ -4,7 +4,8 @@ * the file carries one. Rationale lives at the apply sites in settings.ts. */ -import { expect, test, describe, beforeEach, mock } from "bun:test"; +import { expect, test, describe, beforeEach } from "bun:test"; +import { mockServices } from "./mock_services.ts"; let changeWorkspaceNameCalls: unknown[] = []; let changeWorkspaceColorCalls: unknown[] = []; @@ -15,7 +16,7 @@ let remoteWebhook: string | undefined = undefined; // Every wmill.* call reachable from pushWorkspaceSettings is stubbed so the // function runs without a backend; only the three we assert on record calls. -mock.module("../gen/services.gen.ts", () => ({ +mockServices({ getSettings: async (_a: { workspace: string }) => ({ webhook: remoteWebhook, color: remoteColor, @@ -45,7 +46,7 @@ mock.module("../gen/services.gen.ts", () => ({ editSlackCommand: async () => {}, setWorkspaceSlackOauthConfig: async () => {}, deleteWorkspaceSlackOauthConfig: async () => {}, -})); +}); const { pushWorkspaceSettings } = await import("../src/core/settings.ts"); diff --git a/cli/test/raw_app_push_policy_unit.test.ts b/cli/test/raw_app_push_policy_unit.test.ts index fbbc7b55c0..5a4eab87d5 100644 --- a/cli/test/raw_app_push_policy_unit.test.ts +++ b/cli/test/raw_app_push_policy_unit.test.ts @@ -6,26 +6,20 @@ * file states, and that the markers still close a deployed open app back down. */ -import { afterAll, beforeEach, expect, mock, test } from "bun:test"; +import { beforeEach, expect, test } from "bun:test"; import { mkdtemp, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { mockServices } from "./mock_services.ts"; let calls: any[] = []; let deployedPolicy: any; /** No app deployed at the path: `getAppByPath` 404s and the push creates one. */ let deployed = true; -// Stub only what no other in-process suite imports, and treat a stub as -// permanent for the run (see "Module mocks" in cli/TESTING.md). These three API -// functions qualify — nothing else in `test/` imports them. `bundle.ts` did not: -// stubbing it left `raw_app_svelte_plugin_unit.test.ts` asserting against an -// empty bundle, which an `afterAll` hand-back did not prevent. So the real -// bundler runs instead, on the app each push writes below. -const realServices = await import("../gen/services.gen.ts"); - -mock.module("../gen/services.gen.ts", () => ({ - ...realServices, +// `bundle.ts` is deliberately not stubbed (see "Module mocks" in +// cli/TESTING.md): the real bundler runs on the app each push writes below. +mockServices({ getAppByPath: async () => { if (!deployed) throw new Error("not found"); return { @@ -41,12 +35,6 @@ mock.module("../gen/services.gen.ts", () => ({ createAppRaw: async (a: unknown) => { calls.push(a); }, -})); - -// Belt and braces: nothing else in-process calls these, and a hand-back is not -// what makes that safe. -afterAll(() => { - mock.module("../gen/services.gen.ts", () => realServices); }); const { pushRawApp } = await import("../src/commands/app/raw_apps.ts"); diff --git a/cli/test/schedule_push_permissioned_as_unit.test.ts b/cli/test/schedule_push_permissioned_as_unit.test.ts index 17a1731494..953f8a415e 100644 --- a/cli/test/schedule_push_permissioned_as_unit.test.ts +++ b/cli/test/schedule_push_permissioned_as_unit.test.ts @@ -9,6 +9,7 @@ import { expect, test, describe, afterAll, beforeEach, mock } from "bun:test"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { mockServices } from "./mock_services.ts"; let updateScheduleCalls: any[] = []; let remotePermissionedAs: string | undefined = "u/svc"; @@ -45,7 +46,7 @@ afterAll(() => { } }); -await mockModule("../gen/services.gen.ts", () => ({ +mockServices({ getSchedule: async () => REMOTE_SCHEDULE(), updateSchedule: async (a: unknown) => { updateScheduleCalls.push(a); @@ -56,7 +57,7 @@ await mockModule("../gen/services.gen.ts", () => ({ is_admin: true, groups: [], }), -})); +}); await mockModule("../src/core/context.ts", (real) => ({ ...real, diff --git a/cli/test/shared_ui_diff_unit.test.ts b/cli/test/shared_ui_diff_unit.test.ts index 052fa58a8e..77328457e4 100644 --- a/cli/test/shared_ui_diff_unit.test.ts +++ b/cli/test/shared_ui_diff_unit.test.ts @@ -5,7 +5,8 @@ * (including no local folder) when the apply would be a no-op. */ -import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import { mockServices } from "./mock_services.ts"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -14,7 +15,7 @@ let remoteFiles: Record = {}; let remoteUnreadable = false; let pushedFiles: Record | undefined; -mock.module("../gen/services.gen.ts", () => ({ +mockServices({ getSharedUi: async (_args: { workspace: string }) => { if (remoteUnreadable) throw new Error("shared UI store unreadable"); return { files: remoteFiles }; @@ -25,7 +26,7 @@ mock.module("../gen/services.gen.ts", () => ({ }) => { pushedFiles = args.requestBody.files; }, -})); +}); const { diffSharedUi, pushSharedUi } = await import( "../src/commands/shared_ui.ts" diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index ac0b775e86..0d70e231cf 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -611,6 +611,7 @@ export async function deployItem( value: variable.value ?? "", is_secret: variable.is_secret, description: variable.description ?? "", + labels: variable.labels, }, alreadyEncrypted: false, }); @@ -622,6 +623,7 @@ export async function deployItem( value: variable.value ?? "", is_secret: variable.is_secret, description: variable.description ?? "", + labels: variable.labels, }, }); } @@ -638,6 +640,7 @@ export async function deployItem( path, value: resource.value ?? "", description: resource.description ?? "", + labels: resource.labels, }, }); } else { @@ -648,6 +651,7 @@ export async function deployItem( value: resource.value ?? "", resource_type: resource.resource_type, description: resource.description ?? "", + labels: resource.labels, }, }); } @@ -689,6 +693,9 @@ export async function deployItem( owners: folder.owners, extra_perms: folder.extra_perms, summary: folder.summary ?? undefined, + // A folder stores cleared labels as NULL and reads back without the field, so an + // absent value has to be sent as [] or the target keeps labels the source removed. + labels: folder.labels ?? [], }, }); } else { @@ -699,6 +706,7 @@ export async function deployItem( owners: folder.owners, extra_perms: folder.extra_perms, summary: folder.summary ?? undefined, + labels: folder.labels, }, }); } diff --git a/docker/DockerfileMultiplayer b/docker/DockerfileMultiplayer deleted file mode 100644 index 192ac06ff5..0000000000 --- a/docker/DockerfileMultiplayer +++ /dev/null @@ -1,9 +0,0 @@ -FROM node:slim - -EXPOSE 3002 -ENV PORT 3002 -ENV HOST 0.0.0.0 - -RUN npm install -g y-websocket - -CMD ["npx", "y-websocket"] \ No newline at end of file diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index b32ad2dd94..84b337a8a1 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -4,18 +4,51 @@ set -e # Windmill Extra Services Entrypoint # Starts LSP, Multiplayer, and Debugger services based on environment variables -# Track PIDs for cleanup +# Track PIDs for cleanup, with a parallel array of names so a dead service can be +# named in the logs rather than reported as a bare PID. PIDS=() +NAMES=() -cleanup() { +# How long a service gets to honour SIGTERM before it is killed outright. The +# exit path below signals itself, so nothing else bounds it there; a wedged +# service would otherwise hold the container open half-dead forever. +SHUTDOWN_GRACE_SECS="${SHUTDOWN_GRACE_SECS:-10}" + +stop_services() { echo "[entrypoint] Shutting down services..." for pid in "${PIDS[@]}"; do if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true fi done + + local i running pid + for ((i = 0; i < SHUTDOWN_GRACE_SECS * 10; i++)); do + running=0 + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + running=1 + fi + done + if [ "$running" -eq 0 ]; then + break + fi + sleep 0.1 + done + + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo "[entrypoint] WARNING: PID $pid did not stop within ${SHUTDOWN_GRACE_SECS}s, killing it" >&2 + kill -9 "$pid" 2>/dev/null || true + fi + done + wait echo "[entrypoint] All services stopped" +} + +cleanup() { + stop_services exit 0 } @@ -116,6 +149,7 @@ if [ "${ENABLE_LSP:-true}" = "true" ]; then cd /pyls PORT=${LSP_PORT:-3001} python3 pyls_launcher.py & PIDS+=($!) + NAMES+=("LSP") echo "[entrypoint] LSP started (PID: ${PIDS[-1]})" fi @@ -125,6 +159,7 @@ if [ "${ENABLE_MULTIPLAYER:-true}" = "true" ]; then cd /multiplayer PORT=${MULTIPLAYER_PORT:-3002} HOST=${HOST:-0.0.0.0} node server.mjs & PIDS+=($!) + NAMES+=("Multiplayer") echo "[entrypoint] Multiplayer started (PID: ${PIDS[-1]})" fi @@ -144,6 +179,7 @@ if [ "${ENABLE_DEBUGGER:-true}" = "true" ]; then bun run dap_debug_service.ts $DEBUGGER_ARGS & PIDS+=($!) + NAMES+=("Debugger") echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})" fi @@ -153,6 +189,7 @@ if [ "${ENABLE_GATEWAY:-true}" = "true" ]; then cd /multiplayer PORT=${GATEWAY_PORT:-3000} node gateway.mjs & PIDS+=($!) + NAMES+=("Gateway") echo "[entrypoint] Gateway started (PID: ${PIDS[-1]})" fi @@ -165,15 +202,27 @@ fi echo "[entrypoint] All enabled services started. Waiting..." -# Wait for any process to exit -wait -n "${PIDS[@]}" 2>/dev/null || true +# Wait for any process to exit. `|| status=$?` also keeps `set -e` from aborting +# here, which would skip the reporting and shutdown below. +status=0 +wait -n "${PIDS[@]}" 2>/dev/null || status=$? -# If one process exits, check which one and report +# Nothing here restarts a dead service, and the health checks in front of this +# container probe a single one, so a dead sibling is invisible and stays dead. +# Name it, stop the rest and exit non-zero, leaving the orchestrator to replace +# the container. for i in "${!PIDS[@]}"; do if ! kill -0 "${PIDS[$i]}" 2>/dev/null; then - echo "[entrypoint] Service (PID: ${PIDS[$i]}) has exited" + echo "[entrypoint] ERROR: ${NAMES[$i]} (PID: ${PIDS[$i]}) has exited" >&2 fi done -# Keep running and wait for remaining processes -wait +stop_services + +# Carry the dead service's own status where there is one, but never exit 0: that +# would read as a successful, deliberate shutdown. +if [ "$status" -eq 0 ]; then + status=1 +fi +echo "[entrypoint] Exiting with status $status so the container is restarted" >&2 +exit "$status" diff --git a/docker/test_entrypoint_extra.sh b/docker/test_entrypoint_extra.sh new file mode 100755 index 0000000000..e0b84da366 --- /dev/null +++ b/docker/test_entrypoint_extra.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# +# Supervision tests for docker/entrypoint-extra.sh. +# +# The real script is run unmodified inside a container, with the four services it +# starts replaced by stubs, so what is under test is the shipped file and the +# shipped bash. The stubs are recognised by the argv the entrypoint uses +# (pyls_launcher.py, server.mjs, dap_debug_service.ts, gateway.mjs), which is +# also what keeps the test honest: change how a service is started and the +# corresponding case here stops matching. +# +# Usage: +# bash docker/test_entrypoint_extra.sh +# +# The image defaults to the base of DockerfileExtra's chain (debian:trixie-slim, +# via windmill-ee-slim), so the bash built-ins behave as they do in the real +# image. Override with ENTRYPOINT_TEST_IMAGE to run it against another one, e.g. +# ENTRYPOINT_TEST_IMAGE=windmill-extra:test bash docker/test_entrypoint_extra.sh + +set -euo pipefail + +IMAGE="${ENTRYPOINT_TEST_IMAGE:-debian:trixie-slim}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="$SCRIPT_DIR/entrypoint-extra.sh" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required to run this test" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# --- the pieces that run inside the container ----------------------------- + +cat > "$WORKDIR/stub" <<'STUB' +#!/bin/bash +# Stands in for python3 / node / bun. Works out which service it was started as +# from its arguments, then either stays up until it is signalled (recording that +# it was stopped) or exits straight away, to play the crashed service. +name=unknown +for arg in "$@"; do + case "$arg" in + pyls_launcher.py) name=lsp ;; + server.mjs) name=multiplayer ;; + dap_debug_service.ts) name=debugger ;; + gateway.mjs) name=gateway ;; + esac +done +echo "stub[$name] started" + +if [ "$name" = "${STUB_IGNORE_TERM:-}" ]; then + # Plays a service that is wedged or slow to honour SIGTERM. + trap 'echo "stub[$name] ignoring SIGTERM"' TERM +else + trap 'echo "stub[$name] got SIGTERM"; touch "/out/$name.stopped"; exit 0' TERM +fi +touch "/out/$name.ready" + +if [ "$name" = "${STUB_DIE_AS:-}" ]; then + # Only die once every stub in $STUB_WAIT_FOR has its trap installed. Without + # this the entrypoint can reach its shutdown while a later stub is still + # starting, and that stub takes SIGTERM's default action instead of running + # its handler — a race in the test, not in the entrypoint. + for _ in $(seq 1 600); do + ready=1 + for other in ${STUB_WAIT_FOR:-}; do + if [ ! -f "/out/$other.ready" ]; then ready=0; fi + done + if [ "$ready" -eq 1 ]; then break; fi + sleep 0.1 + done + echo "stub[$name] exiting with ${STUB_DIE_CODE:-1}" + exit "${STUB_DIE_CODE:-1}" +fi +# `sleep & wait` rather than a bare `sleep`, so the trap runs as soon as the +# signal arrives instead of after the sleep returns. The loop matters: bash +# resumes after an interrupted `wait`, so without it even the trap that only +# logs would fall off the end of the script and exit. +while true; do + sleep 3000 & + wait $! +done +STUB + +cat > "$WORKDIR/harness.sh" <<'HARNESS' +#!/bin/bash +# Runs inside the container: installs the stubs, then drives the real +# /entrypoint.sh through each scenario. +set -uo pipefail + +mkdir -p /pyls /multiplayer /debugger /out +for tool in python3 node bun; do + cp /harness/stub "/usr/local/bin/$tool" + chmod +x "/usr/local/bin/$tool" +done + +export ENABLE_LSP=true ENABLE_MULTIPLAYER=true ENABLE_DEBUGGER=true ENABLE_GATEWAY=true +# Every stub the dying one must see started before it exits. +export STUB_WAIT_FOR="lsp multiplayer debugger gateway" + +failures=0 +check() { + local what="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo " ok: $what is $expected" + else + echo " FAIL: $what: expected $expected, got $actual" + failures=$((failures + 1)) + fi +} + +check_log() { + local what="$1" pattern="$2" + if grep -qE "$pattern" /out/log; then + echo " ok: $what" + else + echo " FAIL: $what (no line matching /$pattern/)" + failures=$((failures + 1)) + fi +} + +stopped() { + [ -f "/out/$1.stopped" ] && echo yes || echo no +} + +# `wait` on a backgrounded entrypoint, with a watchdog that SIGKILLs it rather +# than letting a wedged shutdown block the run. Sets $wait_status. +wait_bounded() { + local pid="$1" secs="${2:-30}" watchdog + (sleep "$secs"; kill -KILL "$pid" 2>/dev/null) & + watchdog=$! + wait "$pid" + wait_status=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null +} + +# Block until the entrypoint has written $1, so no scenario acts on a script that +# has not reached the state it is about to be tested in. +await_log() { + local pattern="$1" + for _ in $(seq 1 600); do + grep -qE "$pattern" /out/log && return 0 + sleep 0.1 + done + echo " FAIL: timed out waiting for /$pattern/ in the entrypoint log" + failures=$((failures + 1)) + return 1 +} + +echo "== a service that exits takes the container down with it" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=multiplayer STUB_DIE_CODE=3 timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 3 "$?" +cat /out/log +check_log "the dead service is named in the log" 'ERROR: Multiplayer \(PID: [0-9]+\) has exited' +# The survivors were signalled and ran their own shutdown, rather than being left +# running or killed outright. +for name in lsp debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" +done + +echo +echo "== a service that exits 0 is still a failure for the container" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=gateway STUB_DIE_CODE=0 timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 1 "$?" +check_log "the dead service is named in the log" 'ERROR: Gateway \(PID: [0-9]+\) has exited' + +echo +echo "== a service that ignores SIGTERM cannot hold the container open" +rm -rf /out && mkdir -p /out +STUB_DIE_AS=multiplayer STUB_DIE_CODE=3 STUB_IGNORE_TERM=lsp SHUTDOWN_GRACE_SECS=2 \ + timeout -k 5 60 bash /entrypoint.sh > /out/log 2>&1 +check "exit status" 3 "$?" +check_log "the wedged service is killed after the grace period" \ + 'WARNING: PID [0-9]+ did not stop within 2s, killing it' +check_log "the shutdown still completes" 'All services stopped' +# The ones that do honour SIGTERM still stop the polite way. +for name in debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" +done + +echo +echo "== SIGTERM is still a clean shutdown" +rm -rf /out && mkdir -p /out +bash /entrypoint.sh > /out/log 2>&1 & +entrypoint_pid=$! +if await_log 'All enabled services started'; then + kill -TERM "$entrypoint_pid" + wait_bounded "$entrypoint_pid" + check "exit status" 0 "$wait_status" + for name in lsp multiplayer debugger gateway; do + check "$name stopped cleanly" yes "$(stopped "$name")" + done +else + kill -KILL "$entrypoint_pid" 2>/dev/null +fi + +echo +echo "== with no services enabled the entrypoint sleeps instead of exiting" +rm -rf /out && mkdir -p /out +ENABLE_LSP=false ENABLE_MULTIPLAYER=false ENABLE_DEBUGGER=false ENABLE_GATEWAY=false \ + bash /entrypoint.sh > /out/log 2>&1 & +entrypoint_pid=$! +if await_log 'Sleeping indefinitely'; then + # Staying up is the correct behaviour here, so there is nothing to wait for + # but the passage of time. + sleep 2 + check "still running" yes "$(kill -0 "$entrypoint_pid" 2>/dev/null && echo yes || echo no)" +fi +# SIGKILL, not SIGTERM: this branch blocks in a foreground `sleep infinity`, so +# bash defers the trap until it returns. That is pre-existing behaviour and is +# not what this test is about. +kill -KILL "$entrypoint_pid" 2>/dev/null +wait_bounded "$entrypoint_pid" + +echo +if [ "$failures" -eq 0 ]; then + echo "all entrypoint supervision checks passed" +else + echo "$failures check(s) failed" +fi +exit "$failures" +HARNESS + +echo "Running entrypoint supervision tests in $IMAGE" + +# --entrypoint: the windmill-extra image's own ENTRYPOINT is ["/entrypoint.sh"] +# (docker/DockerfileExtra), which would swallow the command and start the real +# services instead of the harness. Harmless for a plain base image. +DOCKER_RUN=(docker run --rm --init + --entrypoint bash + -v "$ENTRYPOINT:/entrypoint.sh:ro" + -v "$WORKDIR:/harness:ro" + "$IMAGE" /harness/harness.sh) + +# Bound the whole run where the host has a `timeout` (GNU coreutils; stock macOS +# ships none), so a stalled image pull cannot hang a CI job. Every scenario +# inside the container is bounded on its own regardless. +if command -v timeout >/dev/null 2>&1; then + timeout "${ENTRYPOINT_TEST_TIMEOUT:-900}" "${DOCKER_RUN[@]}" +else + "${DOCKER_RUN[@]}" +fi diff --git a/docs/docker-security.md b/docs/docker-security.md index 5b5196f8b4..44ae6cbeff 100644 --- a/docs/docker-security.md +++ b/docs/docker-security.md @@ -22,9 +22,8 @@ so patching here is sufficient: - `DockerfileExtra` builds `FROM windmill-ee-slim`. The nsjail *builder* stages are throwaway (only the compiled `nsjail` binary is -copied out), so they are intentionally not upgraded. `DockerfileMultiplayer` -(`node:slim`), the CLI, Caddy-L4, CUDA-only, and RHEL/dnf images are out of scope -for this apt-based patching. +copied out), so they are intentionally not upgraded. The CLI, Caddy-L4, +CUDA-only, and RHEL/dnf images are out of scope for this apt-based patching. ## Why `apt-get upgrade` and not `unattended-upgrades` / pinning diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md new file mode 100644 index 0000000000..fb9d4051bb --- /dev/null +++ b/docs/operator-write-rights.md @@ -0,0 +1,96 @@ +# Operator write rights + +Most of `workspace_settings.operator_settings` is visibility: flags that hide pages from operators +so the UI stays uncluttered. They are not enforced, and were never meant to be. + +`manage_schedules` and `manage_triggers` are different. They are **enforced** on the write paths, +because hiding the schedules page never stopped an operator creating a schedule through the API, +the CLI or MCP. An admin who wants operators to see what is scheduled without letting them change +it could not express that with a visibility flag alone. + +## Where the gate lives + +On the router, not in the handlers. `gate_operator_writes` is layered in `windmill-api/src/lib.rs` +over the schedules router, the trigger routers, the native-trigger routers and capture, and refuses +anything that is not a GET/HEAD/OPTIONS. + +That is not a style choice. A trigger kind can register routes of its own beside the shared CRUD +ones — bulk HTTP creation, the Postgres publication and replication-slot setup — and those are +hand-written, one per feature. A check inside each handler misses every one of those extra routes, +along with the whole native-trigger family, which does not use the shared handlers at all. On the +router the author of the next route writes nothing and is covered anyway. + +**A layer only covers the routers it is on.** It closes routes added *inside* a gated router; it +says nothing about a new feature that performs trigger writes from a router of its own. Capture is +exactly that — it configures a trigger without creating one, and saving a Postgres capture config +creates a replication slot and a publication on the target database — and it needed its own layer +rather than inheriting one. That includes `move`, which re-points existing capture configs between +runnables: the builders, which call it on every script and flow creation, skip it while the right is +withdrawn, since captures only arrive through a saved config. Before adding a feature that writes +trigger or schedule state, ask which router it lands on. + +Three consequences to keep in mind when adding a route under one of these: + +- A read served over POST gets refused, and an unprompted refusal reaches the operator as a bare + privilege toast. The connection test is button-fired, so it only refuses someone who asked. The + HTTP route and email address availability checks and the Azure scope and topic lists run on + editor open, so their config sections skip them while the lock is set. Put new reads on GET; if + one must stay POST, check nothing fires it unprompted. +- Anything mounted under a gated router inherits the gate. The native-trigger mount also carries + the workspace's integration setup, which is a settings concern, so the layer goes on the trigger + routes alone rather than the whole mount. +- A route operating on *jobs* rather than configuration inherits it too. + `resume_suspended_trigger_jobs` and its cancel twin stay gated: an operator who can neither + suspend nor un-suspend a trigger should not override the consequence. Weigh the next one rather + than taking the router's answer. + +`check_operator_can_manage` is still the function underneath, for a write that cannot be reached +through one of these routers. `/acls/add` and `/acls/remove` are the case that needs it: sharing an +object is a write to it, but that router serves every kind there is, so it can only be gated per +kind from inside the handlers. `manage_kind_for_acl_kind` holds that list, spelled out rather than +matched on the `_trigger` suffix so a kind named otherwise cannot slip through ungated. + +It refuses with `PermissionDenied` (403), never `NotAuthorized` (401): the frontend reads an +uncaught 401 as an expired session and logs the user out, so 401 here ejects an operator from the +app rather than telling them why. Both integration tests assert the status for that reason. + +## In the UI + +The shared lists (`TriggerList`, `SchedulesList`, `NativeTriggerTable`) derive a per-row `canEdit` +from `canWrite && !$lock` and leave `canWrite` itself alone, because `canWrite` also tells +`SharedBadge` whether a row belongs to someone else — fold the lock into it and every row, +including ones the operator owns and has never shared, claims to be shared read-only. Gate write +affordances on `canEdit`, never the badge. + +Each schedule and trigger editor folds the lock into its own `can_write`, so a withdrawn operator +gets the same read-only editor as someone without write access to the folder. There, unlike the +list pages, nothing reads `can_write` as a sharing hint. It is derived for a new trigger too, so the +toolbar, which takes its permissions from `can_write`, needs no lock of its own. Sharing is gated in +`ShareModal`, which locks itself off the kind it was opened on rather than relying on each of the +dozen menu entries that open it. + +The cache is per process, so withdrawing a right has to reach every replica: an `AFTER UPDATE OF +operator_settings` trigger writes a `notify_operator_settings_change` row and `process_notify_event` +drops the entry. Keep both ends if you touch either, or a workspace that withdrew a right keeps +authorizing writes on every other replica until its own entry expires. + +## Granted unless withdrawn + +These name capabilities operators already hold, so absence has to mean "never configured", not a +value. That is easy to get wrong in two places, and the obvious implementation gets both wrong: + +- The read coalesces to **true** (`operator_manage_rights`), including for a workspace with no + `workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to + false instead revokes the right on upgrade for every workspace that ever saved operator settings, + since those rows carry explicit keys and none of them is this one. +- The update endpoint **merges** into the stored jsonb and takes these two as `Option`, so an + omitted key keeps its stored value. It has to: `operator_settings` is git-synced as a whole + object (`cli/src/core/settings.ts` posts the file's contents verbatim), so a settings file + written before these keys existed reaches the endpoint on every pull. A serde or SQL default of + either polarity turns that pull into a silent withdrawal or a silent restoration. + +The visibility flags are plain `bool` and always serialize, so the merge is a no-op for them and +their behaviour is unchanged. + +Do not model a right that an admin *grants* on these. Granted-by-default and granted-on-request +are opposite polarities, and a gate written for one is wrong for the other. diff --git a/examples/deploy/aws-ecs-terraform/README.md b/examples/deploy/aws-ecs-terraform/README.md index 098943e353..f9a0318d7a 100644 --- a/examples/deploy/aws-ecs-terraform/README.md +++ b/examples/deploy/aws-ecs-terraform/README.md @@ -32,14 +32,13 @@ Windmill relies on a PostgreSQL database which will be created by Terraform. The The following stack will be deployed by this terraform as it is provided: - 2 Windmill servers (each requiring 1 CPU and 1.5GiB of Memory) - 2 multi-purpose Windmill workers (each requiring 2 CPU and 3GiB of Memory) -- Windmill LSP (requiring 1 CPU and 1.5GiB of Memory) -- Windmill Multiplayer (requiring 1 CPU and 1.5GiB of Memory) +- Windmill Extra: LSP and Multiplayer, plus the optional Debugger (requiring 1 CPU and 1.5GiB of Memory) - 1 native Windmill worker (requiring 2 CPU and 3GiB of Memory) - 1 high performance Windmill worker (requiring 4 CPU and 15GiB of Memory) -The ECS cluster will be composed of 1 autoscaling group composed of up-to 6 `t3.medium` instances (5 necessary for the entire load, and 1 more for rolling upgrades). This will host all the services except the high performance workers. Those will be deployed on a separate auto-scaling group composed of up-to 2 `t3.xlarge` instances. +The ECS cluster will be composed of 1 autoscaling group of `t3.medium` instances, sized by the ECS capacity provider up to `max_size` (10) in [ecs_cluster.tf](./ecs_cluster.tf). In our test deployment it settled on 5 instances for this load; the headroom covers rolling upgrades. This will host all the services except the high performance workers. Those will be deployed on a separate auto-scaling group composed of up-to 2 `t3.xlarge` instances. -Of course the above is provided as an example, it should be tuned for you own needs, both in terms of instance specs, but also in terms of service architecture. For example, you might not need high performance workers, in which case you won't need the second autoscaling group at all. Same for the native worker, multiplayer, or even LSP (though LSP is highly recommended for a better coding experience). +Of course the above is provided as an example, it should be tuned for your own needs, both in terms of instance specs, but also in terms of service architecture. For example, you might not need high performance workers, in which case you won't need the second autoscaling group at all. Same for the native worker, or even Windmill Extra (though its LSP is highly recommended for a better coding experience). Its services are switched on and off with the `ENABLE_LSP`, `ENABLE_MULTIPLAYER` (Enterprise Edition) and `ENABLE_DEBUGGER` variables in [windmill_extra.tf](./windmill_extra.tf). The only strictly required components are: - At least 1 Windmill server @@ -49,14 +48,13 @@ This terraform has been assembled to easily remove components. Each component me - _REQUIRED_ Windmill server -> [windmill_server.tf](./windmill_server.tf) - _REQUIRED_ Multi-purpose windmill worker -> [windmill_worker_basic.tf](./windmill_worker_basic.tf) -- _OPTIONAL_ Windmill LSP -> [windmill_lsp.tf](./windmill_lsp.tf) -- _OPTIONAL_ Windmill Multiplayer -> [windmill_multiplayer.tf](./windmill_multiplayer.tf) +- _OPTIONAL_ Windmill Extra (LSP, Multiplayer, Debugger) -> [windmill_extra.tf](./windmill_extra.tf) - _OPTIONAL_ Windmill Native worker -> [windmill_worker_native.tf](./windmill_worker_native.tf) - _OPTIONAL_ Windmill High Performace worker -> [windmill_worker_high_performance.tf](./windmill_worker_high_performance.tf) - this one is a lot longer because it deploys a new auto scaling group ### Network -The network deployed here is a single VPC, composed on 4 subnets spread across 2 availability zones (1 public and 1 private subnet per zone). All the services are behind a load balancer routing the request to Windmill server, LSP or multiplayer. +The network deployed here is a single VPC, composed on 4 subnets spread across 2 availability zones (1 public and 1 private subnet per zone). All the services are behind a load balancer routing the request to Windmill server or Windmill Extra (`/ws/*`, `/ws_mp/*`, `/ws_debug/*`). A single security group is used, allowing HTTP traffic on port 80 (it is not deploying custom certificates and therefore does not use HTTPS). @@ -66,6 +64,12 @@ All this is provided as an example. It can be refined depending on your needs. A Windmill heavily relies on PostgreSQL database. This terraform deploys a standalone RDS instance having 100GiB of storage, which can be scaled up to 1000GiB. For production use cases, we recommend deploying a Multi-AZ instance, or even a Multi-AZ DB cluster. The RDS definition is in [rds.tf](./rds.tf) +### Upgrading a stack deployed before Windmill Extra + +Earlier versions of this example ran the LSP and Multiplayer as two separate services (`windmill_lsp.tf` and `windmill_multiplayer.tf`). A plain `terraform apply` of this version migrates such a stack: the `moved` blocks in [load_balancer.tf](./load_balancer.tf) keep the listener rule in place, and the old services and target groups are removed. + +While the new `windmill-extra` task starts (about 2.5 minutes in our test), the `/ws/*` and `/ws_mp/*` routes answer 503, so code intelligence and multiplayer are unavailable in the editor until it is healthy. The debugger (`/ws_debug/*`) stays off in this example unless you set `ENABLE_DEBUGGER=true` in [windmill_extra.tf](./windmill_extra.tf). The Windmill servers and workers are not affected. + ### Ready? REMINDER: don't forget to edit [terraform.tfvars](./terraform.tfvars) before deploying the stack. diff --git a/examples/deploy/aws-ecs-terraform/ecs_cluster.tf b/examples/deploy/aws-ecs-terraform/ecs_cluster.tf index ee5f74d4c4..ddbaad35cf 100644 --- a/examples/deploy/aws-ecs-terraform/ecs_cluster.tf +++ b/examples/deploy/aws-ecs-terraform/ecs_cluster.tf @@ -17,9 +17,10 @@ resource "aws_launch_template" "windmill_cluster_lt" { } network_interfaces { - device_index = 0 - delete_on_termination = true - associate_public_ip_address = true + device_index = 0 + delete_on_termination = true + # Private subnets: outbound traffic goes through the NAT gateways, so no public IP. + associate_public_ip_address = false security_groups = [ aws_security_group.windmill_cluster_sg.id ] @@ -57,6 +58,16 @@ resource "aws_autoscaling_group" "windmill_cluster_asg" { value = true propagate_at_launch = true } + + # The ECS agent on these instances reaches AWS through private route -> NAT gateway -> public + # route -> internet gateway. Keeping that whole path until the instances are gone lets + # `terraform destroy` drain and delete the ECS services, which otherwise hang in DRAINING. + depends_on = [ + aws_route_table_association.windmill_cluster_subnet_private1__rtb_private1, + aws_route_table_association.windmill_cluster_subnet_private2__rtb_private2, + aws_route_table_association.windmill_cluster_subnet_public1__rtb_public, + aws_route_table_association.windmill_cluster_subnet_public2__rtb_public, + ] } resource "aws_ecs_cluster" "windmill_cluster" { diff --git a/examples/deploy/aws-ecs-terraform/load_balancer.tf b/examples/deploy/aws-ecs-terraform/load_balancer.tf index 376374efb6..f73704f04e 100644 --- a/examples/deploy/aws-ecs-terraform/load_balancer.tf +++ b/examples/deploy/aws-ecs-terraform/load_balancer.tf @@ -6,20 +6,20 @@ resource "aws_lb_target_group" "windmill_cluster_windmill_server_tg" { vpc_id = aws_vpc.windmill_cluster_vpc.id } -resource "aws_lb_target_group" "windmill_cluster_windmill_lsp_tg" { - name = "windmill-cluster-lsp-tg" - port = 3001 +resource "aws_lb_target_group" "windmill_cluster_windmill_extra_tg" { + # A prefix, not a fixed name: with create_before_destroy the replacement exists alongside the old + # group for a moment, and target group names must be unique. + name_prefix = "wmext-" + port = 3000 protocol = "HTTP" target_type = "ip" vpc_id = aws_vpc.windmill_cluster_vpc.id -} -resource "aws_lb_target_group" "windmill_cluster_windmill_multiplayer_tg" { - name = "windmill-cluster-multiplayer-tg" - port = 3002 - protocol = "HTTP" - target_type = "ip" - vpc_id = aws_vpc.windmill_cluster_vpc.id + # Replacing a target group that a listener rule uses: create the new one and repoint the rule + # before deleting the old one, which the ALB refuses while the rule still references it. + lifecycle { + create_before_destroy = true + } } resource "aws_lb" "windmill_cluster_alb" { @@ -48,34 +48,32 @@ resource "aws_lb_listener" "windmill_cluster_alb_listener" { } } -resource "aws_lb_listener_rule" "windmill_cluster_alb_lsp_rule" { +resource "aws_lb_listener_rule" "windmill_cluster_alb_extra_rule" { listener_arn = aws_lb_listener.windmill_cluster_alb_listener.arn priority = 100 action { type = "forward" - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_lsp_tg.arn + target_group_arn = aws_lb_target_group.windmill_cluster_windmill_extra_tg.arn } condition { path_pattern { - values = ["/ws/*"] + values = ["/ws/*", "/ws_mp/*", "/ws_debug/*"] } } } -resource "aws_lb_listener_rule" "windmill_cluster_alb_multiplayer_rule" { - listener_arn = aws_lb_listener.windmill_cluster_alb_listener.arn - priority = 50 +# Upgrading a stack created before windmill-extra (standalone LSP and multiplayer services). +# The LSP rule becomes the extra rule, updated in place: it keeps priority 100, where destroying it and +# creating a new rule with the same priority can fail with PriorityInUse. The LSP target group becomes +# the extra target group, replaced before destroy (see create_before_destroy above). +moved { + from = aws_lb_listener_rule.windmill_cluster_alb_lsp_rule + to = aws_lb_listener_rule.windmill_cluster_alb_extra_rule +} - action { - type = "forward" - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_multiplayer_tg.arn - } - - condition { - path_pattern { - values = ["/ws_mp/*"] - } - } +moved { + from = aws_lb_target_group.windmill_cluster_windmill_lsp_tg + to = aws_lb_target_group.windmill_cluster_windmill_extra_tg } diff --git a/examples/deploy/aws-ecs-terraform/rds.tf b/examples/deploy/aws-ecs-terraform/rds.tf index 610f4a61d2..7cceec7494 100644 --- a/examples/deploy/aws-ecs-terraform/rds.tf +++ b/examples/deploy/aws-ecs-terraform/rds.tf @@ -19,7 +19,7 @@ resource "aws_db_instance" "windmill_cluster_rds" { # iops = 3000 engine = "postgres" - engine_version = "16.1" + engine_version = "16" parameter_group_name = "default.postgres16" license_model = "postgresql-license" @@ -39,4 +39,11 @@ resource "aws_db_instance" "windmill_cluster_rds" { backup_retention_period = 7 skip_final_snapshot = true deletion_protection = false + + # engine_version picks the major version when the database is created; RDS then applies minor + # upgrades itself. Changing it on an existing instance would ask RDS to "upgrade" to the bare major + # version, which it rejects, so Terraform leaves it alone after creation. + lifecycle { + ignore_changes = [engine_version] + } } \ No newline at end of file diff --git a/examples/deploy/aws-ecs-terraform/vpc.tf b/examples/deploy/aws-ecs-terraform/vpc.tf index 6e9c66c6bf..79f0a815a1 100644 --- a/examples/deploy/aws-ecs-terraform/vpc.tf +++ b/examples/deploy/aws-ecs-terraform/vpc.tf @@ -69,6 +69,9 @@ resource "aws_nat_gateway" "windmill_cluster_nat_gateway_public1" { tags = { "Name" = "windmill-cluster-nat-gateway-public1" } + + # Created after, and deleted before, the internet gateway it routes through. + depends_on = [aws_internet_gateway.windmill_cluster_internet_gateway] } resource "aws_eip" "windmill_cluster_nat_gateway_public2_eip" { @@ -81,6 +84,9 @@ resource "aws_nat_gateway" "windmill_cluster_nat_gateway_public2" { tags = { "Name" = "windmill-cluster-nat-gateway-public2" } + + # Created after, and deleted before, the internet gateway it routes through. + depends_on = [aws_internet_gateway.windmill_cluster_internet_gateway] } resource "aws_route_table" "windmill_cluster_rtb_public" { diff --git a/examples/deploy/aws-ecs-terraform/windmill_extra.tf b/examples/deploy/aws-ecs-terraform/windmill_extra.tf new file mode 100644 index 0000000000..ecb4de052d --- /dev/null +++ b/examples/deploy/aws-ecs-terraform/windmill_extra.tf @@ -0,0 +1,126 @@ +# windmill-extra: LSP, Multiplayer and Debugger in one image, behind a gateway on port 3000 +# that routes /ws/* (LSP), /ws_mp/* (Multiplayer) and /ws_debug/* (Debugger). +resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_extra_log_group" { + name = "/ecs/windmill-extra" +} + +resource "aws_ecs_task_definition" "windmill_cluster_windmill_extra_td" { + family = "windmill-extra" + network_mode = "awsvpc" + execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn + cpu = 1024 + memory = 1536 + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + requires_compatibilities = ["EC2"] + + container_definitions = jsonencode([ + { + name = "windmill-extra" + image = "ghcr.io/windmill-labs/windmill-extra:latest" + cpu = 1024 + memory = 1536 + essential = true + portMappings = [ + { + name = "http" + containerPort = 3000 + hostPort = 3000 + protocol = "tcp" + appProtocol = "http" + } + ] + environment = [ + { + name = "JSON_FMT" + value = "true" + }, + { + # The target group health check (GET /) is answered by the LSP: keep it enabled, or set a + # health_check path on windmill_cluster_windmill_extra_tg that another enabled service answers. + name = "ENABLE_LSP" + value = "true" + }, + { + # Real-time collaboration, Enterprise Edition only. + name = "ENABLE_MULTIPLAYER" + value = "true" + }, + { + # Keep REQUIRE_SIGNED_DEBUG_REQUESTS=true on any internet-reachable deployment. + name = "ENABLE_DEBUGGER" + value = "false" + }, + { + name = "REQUIRE_SIGNED_DEBUG_REQUESTS" + value = "true" + }, + { + # Multiplayer and the debugger verify the tokens the Windmill server signs, with the key + # they fetch from /api/debug/jwks. Without it, every multiplayer + # session is rejected. Use https:// if you add a TLS listener. + name = "WINDMILL_BASE_URL" + value = "http://${aws_lb.windmill_cluster_alb.dns_name}" + }, + ] + mountPoints = [ + { + sourceVolume = "lsp_cache" + containerPath = "/pyls/.cache" + } + ] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_extra_log_group.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "ecs" + } + } + } + ]) + + volume { + name = "lsp_cache" + host_path = "/pyls/.cache" + } +} + +resource "aws_ecs_service" "windmill_cluster_windmill_extra_service" { + name = "windmill-extra" + cluster = aws_ecs_cluster.windmill_cluster.id + task_definition = aws_ecs_task_definition.windmill_cluster_windmill_extra_td.arn + desired_count = 1 + + network_configuration { + subnets = [ + aws_subnet.windmill_cluster_subnet_private1.id, + aws_subnet.windmill_cluster_subnet_private2.id, + ] + security_groups = [aws_security_group.windmill_cluster_sg.id] + } + + force_new_deployment = true + placement_constraints { + type = "distinctInstance" + } + + capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name + weight = 100 + } + + load_balancer { + target_group_arn = aws_lb_target_group.windmill_cluster_windmill_extra_tg.arn + container_name = "windmill-extra" + container_port = 3000 + } + + # ECS rejects a service whose target group is not yet attached to the load balancer. + depends_on = [ + aws_autoscaling_group.windmill_cluster_asg, + aws_lb_listener_rule.windmill_cluster_alb_extra_rule, + ] +} diff --git a/examples/deploy/aws-ecs-terraform/windmill_lsp.tf b/examples/deploy/aws-ecs-terraform/windmill_lsp.tf deleted file mode 100644 index 02bd2c5d56..0000000000 --- a/examples/deploy/aws-ecs-terraform/windmill_lsp.tf +++ /dev/null @@ -1,85 +0,0 @@ -resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_lsp_log_group" { - name = "/ecs/windmill-lsp" -} - -resource "aws_ecs_task_definition" "windmill_cluster_windmill_lsp_td" { - family = "windmill-lsp" - network_mode = "awsvpc" - execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn - cpu = 1024 - memory = 1536 - runtime_platform { - operating_system_family = "LINUX" - cpu_architecture = "X86_64" - } - requires_compatibilities = ["EC2"] - - container_definitions = jsonencode([ - { - name = "windmill-lsp" - image = "ghcr.io/windmill-labs/windmill-lsp:latest" - cpu = 1024 - memory = 1536 - essential = true - portMappings = [ - { - name = "http" - containerPort = 3001 - hostPort = 3001 - protocol = "tcp" - appProtocol = "http" - } - ] - environment = [{ - name = "JSON_FMT" - value = "true" - }] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_lsp_log_group.name - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = "ecs" - } - } - } - ]) - - volume { - name = "lsp_cache" - host_path = "/pyls/.cache" - } -} - -resource "aws_ecs_service" "windmill_cluster_windmill_lsp_service" { - name = "windmill-lsp" - cluster = aws_ecs_cluster.windmill_cluster.id - task_definition = aws_ecs_task_definition.windmill_cluster_windmill_lsp_td.arn - desired_count = 1 - - network_configuration { - subnets = [ - aws_subnet.windmill_cluster_subnet_private1.id, - aws_subnet.windmill_cluster_subnet_private2.id, - ] - security_groups = [aws_security_group.windmill_cluster_sg.id] - } - - force_new_deployment = true - placement_constraints { - type = "distinctInstance" - } - - capacity_provider_strategy { - capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name - weight = 100 - } - - load_balancer { - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_lsp_tg.arn - container_name = "windmill-lsp" - container_port = 3001 - } - - depends_on = [aws_autoscaling_group.windmill_cluster_asg] -} diff --git a/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf b/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf deleted file mode 100644 index a9ccfa0ee2..0000000000 --- a/examples/deploy/aws-ecs-terraform/windmill_multiplayer.tf +++ /dev/null @@ -1,80 +0,0 @@ -resource "aws_cloudwatch_log_group" "windmill_cluster_windmill_multiplayer_log_group" { - name = "/ecs/windmill-multiplayer" -} - -resource "aws_ecs_task_definition" "windmill_cluster_windmill_multiplayer_td" { - family = "windmill-multiplayer" - network_mode = "awsvpc" - execution_role_arn = data.aws_iam_role.ecs_task_execution_role.arn - cpu = 1024 - memory = 1536 - runtime_platform { - operating_system_family = "LINUX" - cpu_architecture = "X86_64" - } - requires_compatibilities = ["EC2"] - - container_definitions = jsonencode([ - { - name = "windmill-multiplayer" - image = "ghcr.io/windmill-labs/windmill-multiplayer:latest" - cpu = 1024 - memory = 1536 - essential = true - portMappings = [ - { - name = "http" - containerPort = 3002 - hostPort = 3002 - protocol = "tcp" - appProtocol = "http" - } - ] - environment = [{ - name = "JSON_FMT" - value = "true" - }] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.windmill_cluster_windmill_multiplayer_log_group.name - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = "ecs" - } - } - } - ]) -} - -resource "aws_ecs_service" "windmill_cluster_windmill_multiplayer_service" { - name = "windmill-multiplayer" - cluster = aws_ecs_cluster.windmill_cluster.id - task_definition = aws_ecs_task_definition.windmill_cluster_windmill_multiplayer_td.arn - desired_count = 1 - - network_configuration { - subnets = [ - aws_subnet.windmill_cluster_subnet_private1.id, - aws_subnet.windmill_cluster_subnet_private2.id, - ] - security_groups = [aws_security_group.windmill_cluster_sg.id] - } - - force_new_deployment = true - placement_constraints { - type = "distinctInstance" - } - - capacity_provider_strategy { - capacity_provider = aws_ecs_capacity_provider.windmill_cluster_capacity_provider.name - weight = 100 - } - - load_balancer { - target_group_arn = aws_lb_target_group.windmill_cluster_windmill_multiplayer_tg.arn - container_name = "windmill-multiplayer" - container_port = 3002 - } - - depends_on = [aws_autoscaling_group.windmill_cluster_asg] -} diff --git a/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf b/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf index 1c7ff33308..f033d31057 100644 --- a/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf +++ b/examples/deploy/aws-ecs-terraform/windmill_worker_high_performance.tf @@ -17,9 +17,10 @@ resource "aws_launch_template" "windmill_cluster_high_performance_lt" { } network_interfaces { - device_index = 0 - delete_on_termination = true - associate_public_ip_address = true + device_index = 0 + delete_on_termination = true + # Private subnets: outbound traffic goes through the NAT gateways, so no public IP. + associate_public_ip_address = false security_groups = [ aws_security_group.windmill_cluster_sg.id ] @@ -57,6 +58,16 @@ resource "aws_autoscaling_group" "windmill_cluster_high_performance_asg" { value = true propagate_at_launch = true } + + # The ECS agent on these instances reaches AWS through private route -> NAT gateway -> public + # route -> internet gateway. Keeping that whole path until the instances are gone lets + # `terraform destroy` drain and delete the ECS services, which otherwise hang in DRAINING. + depends_on = [ + aws_route_table_association.windmill_cluster_subnet_private1__rtb_private1, + aws_route_table_association.windmill_cluster_subnet_private2__rtb_private2, + aws_route_table_association.windmill_cluster_subnet_public1__rtb_public, + aws_route_table_association.windmill_cluster_subnet_public2__rtb_public, + ] } resource "aws_ecs_capacity_provider" "windmill_cluster_high_performance_capacity_provider" { diff --git a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml index 5f2b4c42dc..c26916aa1e 100644 --- a/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml +++ b/examples/deploy/aws-eks-cloudformation/quicklaunch.yaml @@ -436,7 +436,7 @@ Resources: --set windmill.baseDomain=windmill.local \ --set windmill.baseProtocol=http \ --set windmill.appReplicas=${WorkerReplicas} \ - --set windmill.lspReplicas=2 \ + --set windmill.extraReplicas=2 \ --set windmill.workerGroups[0].name=default \ --set windmill.workerGroups[0].mode=worker \ --set windmill.workerGroups[0].replicas=${WorkerReplicas} \ diff --git a/examples/deploy/otel-tracing-grafana/docker-compose.yml b/examples/deploy/otel-tracing-grafana/docker-compose.yml index 525010efad..d75caa0959 100644 --- a/examples/deploy/otel-tracing-grafana/docker-compose.yml +++ b/examples/deploy/otel-tracing-grafana/docker-compose.yml @@ -136,29 +136,34 @@ services: - windmill_index:/tmp/windmill/search - worker_logs:/tmp/windmill/logs - lsp: - image: ghcr.io/windmill-labs/windmill-lsp:latest + # Combined extra services behind one gateway on port 3000: LSP, Multiplayer and Debugger. + # Caddy routes /ws/*, /ws_mp/* and /ws_debug/* here (see the Caddyfile at the repo root). + # - ENABLE_LSP=true (default) - Language Server Protocol for code intelligence + # - ENABLE_MULTIPLAYER=false - Real-time collaboration (Enterprise Edition) + # - ENABLE_DEBUGGER=false - Interactive debugging via DAP WebSocket + windmill_extra: + image: ghcr.io/windmill-labs/windmill-extra:latest pull_policy: always restart: unless-stopped expose: - - 3001 + - 3000 + environment: + - ENABLE_LSP=true + - ENABLE_MULTIPLAYER=false # Set to true to enable multiplayer (Enterprise Edition) + - ENABLE_DEBUGGER=false # Set to true to enable the debugger + - REQUIRE_SIGNED_DEBUG_REQUESTS=true # Do NOT set to false on any internet-reachable deployment + - WINDMILL_BASE_URL=http://windmill_server:8000 volumes: - - lsp_cache:/root/.cache - - multiplayer: - image: ghcr.io/windmill-labs/windmill-multiplayer:latest - deploy: - replicas: 0 # Set to 1 to enable multiplayer, only available on Enterprise Edition - restart: unless-stopped - expose: - - 3002 + - lsp_cache:/pyls/.cache caddy: - image: ghcr.io/windmill-labs/caddy-l4:latest + # Uses the Caddyfile at the repo root. The two are version-coupled: keep this tag in step with + # the caddy image in the root docker-compose.yml. + image: ghcr.io/windmill-labs/caddy-l4:2.11.4-1 restart: unless-stopped # Configure the mounted Caddyfile and the exposed ports or use another reverse proxy if needed volumes: - - ./Caddyfile:/etc/caddy/Caddyfile + - ../../../Caddyfile:/etc/caddy/Caddyfile # - ./certs:/certs # Provide custom certificate files like cert.pem and key.pem to enable HTTPS - See the corresponding section in the Caddyfile ports: # To change the exposed port, simply change 80:80 to :80. No other changes needed diff --git a/examples/deploy/otel-tracing-jaeger/docker-compose.yml b/examples/deploy/otel-tracing-jaeger/docker-compose.yml index 3b186b45fb..b8db8af57e 100644 --- a/examples/deploy/otel-tracing-jaeger/docker-compose.yml +++ b/examples/deploy/otel-tracing-jaeger/docker-compose.yml @@ -136,29 +136,34 @@ services: - windmill_index:/tmp/windmill/search - worker_logs:/tmp/windmill/logs - lsp: - image: ghcr.io/windmill-labs/windmill-lsp:latest + # Combined extra services behind one gateway on port 3000: LSP, Multiplayer and Debugger. + # Caddy routes /ws/*, /ws_mp/* and /ws_debug/* here (see the Caddyfile at the repo root). + # - ENABLE_LSP=true (default) - Language Server Protocol for code intelligence + # - ENABLE_MULTIPLAYER=false - Real-time collaboration (Enterprise Edition) + # - ENABLE_DEBUGGER=false - Interactive debugging via DAP WebSocket + windmill_extra: + image: ghcr.io/windmill-labs/windmill-extra:latest pull_policy: always restart: unless-stopped expose: - - 3001 + - 3000 + environment: + - ENABLE_LSP=true + - ENABLE_MULTIPLAYER=false # Set to true to enable multiplayer (Enterprise Edition) + - ENABLE_DEBUGGER=false # Set to true to enable the debugger + - REQUIRE_SIGNED_DEBUG_REQUESTS=true # Do NOT set to false on any internet-reachable deployment + - WINDMILL_BASE_URL=http://windmill_server:8000 volumes: - - lsp_cache:/root/.cache - - multiplayer: - image: ghcr.io/windmill-labs/windmill-multiplayer:latest - deploy: - replicas: 0 # Set to 1 to enable multiplayer, only available on Enterprise Edition - restart: unless-stopped - expose: - - 3002 + - lsp_cache:/pyls/.cache caddy: - image: ghcr.io/windmill-labs/caddy-l4:latest + # Uses the Caddyfile at the repo root. The two are version-coupled: keep this tag in step with + # the caddy image in the root docker-compose.yml. + image: ghcr.io/windmill-labs/caddy-l4:2.11.4-1 restart: unless-stopped # Configure the mounted Caddyfile and the exposed ports or use another reverse proxy if needed volumes: - - ./Caddyfile:/etc/caddy/Caddyfile + - ../../../Caddyfile:/etc/caddy/Caddyfile # - ./certs:/certs # Provide custom certificate files like cert.pem and key.pem to enable HTTPS - See the corresponding section in the Caddyfile ports: # To change the exposed port, simply change 80:80 to :80. No other changes needed diff --git a/frontend/README_DEV.md b/frontend/README_DEV.md index 9848f333ae..68aa3d7bc9 100644 --- a/frontend/README_DEV.md +++ b/frontend/README_DEV.md @@ -90,6 +90,24 @@ You can configure another proxy to use like so: REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev ``` +`REMOTE_LSP` needs a language server on port 3001. The `windmill-extra` image serves one: + +```bash +docker run --rm -p 3001:3001 ghcr.io/windmill-labs/windmill-extra:latest +``` + +To proxy multiplayer and the debugger as well, publish the image's gateway on port 3000 and use `REMOTE_EXTRA`, which +covers `/ws/*`, `/ws_mp/*` and `/ws_debug/*`. Those two services verify tokens signed by your backend, so give the +container its URL (on Linux, add `--add-host=host.docker.internal:host-gateway`): + +```bash +docker run --rm -p 3000:3000 -e WINDMILL_BASE_URL=http://host.docker.internal:8000 ghcr.io/windmill-labs/windmill-extra:latest +REMOTE=http://127.0.0.1:8000 REMOTE_EXTRA=http://127.0.0.1:3000 npm run dev +``` + +To try local changes to `lsp/pyls_launcher.py` or `lsp/Pipfile`, build that image from the repo root with +`docker build -f docker/DockerfileExtra -t windmill-extra-dev .` and run `windmill-extra-dev` instead. + ### Run dev servers on demand A dev server costs 1.1-1.7 GB resident once a page has been browsed, which adds up when diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 611cd0c91f..42e9804adc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.817.0", + "version": "1.819.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.817.0", + "version": "1.819.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 46db11793f..db71f92e1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.817.0", + "version": "1.819.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/accentColor.test.ts b/frontend/src/lib/accentColor.test.ts new file mode 100644 index 0000000000..6d3b855240 --- /dev/null +++ b/frontend/src/lib/accentColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { accentStylesheet } from './accentColor' + +describe('accentStylesheet', () => { + it('refuses anything but #rrggbb, since the value lands in a stylesheet', () => { + for (const bad of ['#000;}body{display:none', '#fff', 'red', '1f9d55', 123, null]) { + expect(accentStylesheet(bad)).toBeUndefined() + } + }) + + it('overrides every accent token with an in-range r g b triplet in each theme', () => { + // A saturated color exercises the gamut clamp, a grey the zero-chroma path. + for (const color of ['#ff0000', '#808080']) { + const rules = accentStylesheet(color)!.split('\n') + expect(rules).toHaveLength(3) + for (const rule of rules) { + const triplets = [...rule.matchAll(/--color-[a-z-]+: (\d+) (\d+) (\d+);/g)] + expect(triplets).toHaveLength(10) + for (const [, ...channels] of triplets) { + for (const c of channels) expect(Number(c)).toBeLessThanOrEqual(255) + } + } + } + }) +}) diff --git a/frontend/src/lib/accentColor.ts b/frontend/src/lib/accentColor.ts new file mode 100644 index 0000000000..f11a96bb79 --- /dev/null +++ b/frontend/src/lib/accentColor.ts @@ -0,0 +1,138 @@ +import tokensFile from './assets/tokens/tokens.json' +import githubDarkTokens from './assets/tokens/githubDark.json' +import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' + +/** Instance-wide accent color, `#rrggbb` (mirrors `ACCENT_COLOR_SETTING` in the backend). */ +export const ACCENT_COLOR_SETTING = 'accent_color' + +const ACCENT_TOKENS = [ + 'surface-accent-primary', + 'surface-accent-hover', + 'surface-accent-clicked', + 'surface-accent-selected', + 'surface-accent-secondary', + 'surface-accent-secondary-hover', + 'surface-accent-secondary-clicked', + 'text-accent', + 'border-accent', + 'border-selected' +] as const + +const STYLE_ID = 'wm-accent-color' +const ACCENT_ATTR = 'data-wm-accent' + +/** + * The stored setting as a color, or `undefined` for "use the default theme". + * + * The backend already rejects anything but `#rrggbb`, but the value is interpolated into a + * stylesheet, so this is the last place that can refuse a row written around the validator. + */ +export function parseAccentColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : undefined +} + +type Oklch = [l: number, c: number, h: number] + +function srgbToLinear(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +function linearToSrgb(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function hexToOklch(hex: string): Oklch { + const [r, g, b] = [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16) / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return [L, Math.hypot(A, B), Math.atan2(B, A)] +} + +/** Linear sRGB channels, possibly out of [0, 1] when the color is outside the gamut. */ +function oklchToLinearRgb([L, C, h]: Oklch): [number, number, number] { + const A = C * Math.cos(h) + const B = C * Math.sin(h) + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3 + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3 + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3 + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ] +} + +/** `r g b` triplet (the format the `--color-*` variables use), chroma reduced into gamut. */ +function oklchToRgbTriplet([L, C, h]: Oklch): string { + const inGamut = (c: number) => + oklchToLinearRgb([L, c, h]).every((v) => v >= -1e-4 && v <= 1 + 1e-4) + let lo = 0 + let hi = C + if (!inGamut(C)) { + for (let i = 0; i < 20; i++) { + const mid = (lo + hi) / 2 + if (inGamut(mid)) lo = mid + else hi = mid + } + } else lo = C + return oklchToLinearRgb([L, lo, h]) + .map((v) => Math.round(Math.min(1, Math.max(0, linearToSrgb(v))) * 255)) + .join(' ') +} + +// Below this chroma the chosen color reads as grey, and the accents fade with it. +const FULL_CHROMA = 0.1 + +/** + * Move each accent token to the chosen hue while keeping its own lightness, so every + * text/background pairing keeps the contrast the default palette was designed with. + * Taking the chosen color verbatim would put white text on a pale yellow button. + */ +function recolor(tokens: Record, accent: Oklch): string { + const chromaScale = Math.min(1, accent[1] / FULL_CHROMA) + return ACCENT_TOKENS.map((name) => { + const [l, c] = hexToOklch(tokens[name]) + return `--color-${name}: ${oklchToRgbTriplet([l, c * chromaScale, accent[2]])};` + }).join('') +} + +/** The accent overrides for every theme, or `undefined` when `value` is not `#rrggbb`. */ +export function accentStylesheet(value: unknown): string | undefined { + const color = parseAccentColor(value) + if (color == undefined) return undefined + const accent = hexToOklch(color) + // `:root[attr]` outranks the `html`, `html.dark` and `html.dark.github-dark` rules + // that define the default tokens, whatever order the stylesheets load in. + return [ + `:root[${ACCENT_ATTR}]{${recolor(tokensFile.tokens[lightModeName], accent)}` + + `--sidebar-bg-light:color-mix(in oklab, ${color} 18%, var(--sidebar-bg-light-base));}`, + `:root[${ACCENT_ATTR}].dark{${recolor(tokensFile.tokens[darkModeName], accent)}` + + `--sidebar-bg-dark:color-mix(in oklab, ${color} 22%, var(--sidebar-bg-dark-base));}`, + `:root[${ACCENT_ATTR}].dark.github-dark{${recolor(githubDarkTokens, accent)}}` + ].join('\n') +} + +/** Apply the instance accent to the whole document, or restore the default with `undefined`. */ +export function applyAccentColor(value: string | undefined): void { + const root = document.documentElement + let style = document.getElementById(STYLE_ID) + const css = accentStylesheet(value) + if (css == undefined) { + style?.remove() + root.removeAttribute(ACCENT_ATTR) + return + } + if (!style) { + style = document.createElement('style') + style.id = STYLE_ID + document.head.appendChild(style) + } + style.textContent = css + root.setAttribute(ACCENT_ATTR, '') +} diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 2255817c5e..9aff73d827 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -126,7 +126,8 @@ host: parts.host, port: parts.port || args?.port, dbname: parts.dbname || args?.dbname, - sslmode: parts.sslmode || args?.sslmode + sslmode: parts.sslmode || args?.sslmode, + options: parts.options || args?.options }, null, 2 diff --git a/frontend/src/lib/components/BedrockCredentialsCheck.svelte b/frontend/src/lib/components/BedrockCredentialsCheck.svelte index fa39309a50..726c6d8f0a 100644 --- a/frontend/src/lib/components/BedrockCredentialsCheck.svelte +++ b/frontend/src/lib/components/BedrockCredentialsCheck.svelte @@ -1,5 +1,6 @@ - - -

-
- - {#if datatableTreeLoading && (datatableTree?.length ?? 0) === 0} -
- - Loading... +{#snippet diagramView(tables: SelectedTable[])} + { + if (!tabs) onViewMode?.('data') + selectTable(currentDatatable, schema, table) + }} + /> +{/snippet} + +{#snippet dataView( + refKey: string, + schema: string | undefined, + table: string | undefined, + isCurrent: boolean +)} + {@const key = tableKeyOf(schema, table)} + {@const schemaDefs = key && !colDefs?.[key]?.length ? provisionalColDefs(schema, table) : []} + {#if key && (colDefs?.[key]?.length || schemaDefs.length)} + {@const dbTableOps = colDefs?.[key]?.length + ? dbTableOpsFactory({ colDefs: colDefs[key], tableKey: key }) + : waitingDbTableOps(schemaDefs, key)} + + {#key key} + (rowFilter = undefined)} + layoutStorageKey={table ? tableLayoutKey?.(schema, table) : undefined} + joinTargets={joinTargetsOf(key)} + onNewColumn={schema && table + ? () => startAlterTable(currentDatatable, schema, table) + : undefined} + bind:this={dbTableRefs[refKey]} + /> + {/key} + {:else if databaseIsEmpty} +
+ Database is empty + {#if onImport} +
+ + {#if !!actingUser?.is_admin || !!$superadmin} + + {/if}
{/if} - {#each treeRoots as root (root.datatable ?? '')} - {@const dtOpen = isExpanded(root.datatable)} - {#if root.datatable !== undefined} - {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} - {@const roleInfo = roleOf(root.datatable)} - - {/if} - {#if dtOpen} - {#if root.error} -

{root.error}

{/if} - {#each root.schemas as sc (sc.schemaKey)} - {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} - {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} - {#if dbSupportsSchemas} + {#each treeRoots as root (root.datatable ?? '')} + {@const dtOpen = isExpanded(root.datatable)} + {#if root.datatable !== undefined} + {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} + {@const roleInfo = roleOf(root.datatable)} {/if} - - - {#if schemaOpen || !dbSupportsSchemas} - {@const tableIndent = dbSupportsSchemas - ? root.datatable !== undefined - ? 'pl-11' - : 'pl-7' - : root.datatable !== undefined - ? 'pl-7' - : 'pl-3'} - {#each sc.tables as tableKey (tableKey)} - {@const entry = { - datatable: root.datatable, - schema: sc.schemaKey, - table: tableKey - }} - {@const hasMenu = !multiSelectMode} - {@const isSelected = - root.datatable === currentDatatable && - selected.schemaKey === sc.schemaKey && - selected.tableKey === tableKey} + {#if dtOpen} + {#if root.error} +

{root.error}

+ {/if} + {#each root.schemas as sc (sc.schemaKey)} + {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} + {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} + {#if dbSupportsSchemas} - {/each} - {#if canCreateTableIn(root.datatable, sc.schemaKey)} - {/if} + + + {#if schemaOpen || !dbSupportsSchemas} + {@const tableIndent = dbSupportsSchemas + ? root.datatable !== undefined + ? 'pl-11' + : 'pl-7' + : root.datatable !== undefined + ? 'pl-7' + : 'pl-3'} + {#each sc.tables as tableKey (tableKey)} + {@const entry = { + datatable: root.datatable, + schema: sc.schemaKey, + table: tableKey + }} + {@const hasMenu = !multiSelectMode} + {@const isSelected = + root.datatable === currentDatatable && + selected.schemaKey === sc.schemaKey && + selected.tableKey === tableKey} + + {/each} + {#if canCreateTableIn(root.datatable, sc.schemaKey)} + + {/if} + {/if} + + {/each} + {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} + {/if} -
- {/each} - {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} - - {/if} - {/if} - {/each} -
- - - {#if mainPane} - {@render mainPane()} - {:else if viewMode === 'diagram'} - { - onViewMode?.('data') - selectTable(currentDatatable, schema, table) - }} - /> - {:else if tableKey && colDefs?.[tableKey]?.length} - {@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })} - (rowFilter = undefined)} - bind:this={_dbTable} - /> - {:else if databaseIsEmpty} -
- Database is empty - {#if onImport} -
- - {#if !!actingUser?.is_admin || !!$superadmin} - {/if} -
+ {/each} +
+
+ + + {#if mainPane} + {@render mainPane()} + {:else if tabs} + + {#each tabs.tabs as tab (tab.id)} + {#if mountedTabs.has(tab.id)} +
+ {#if tab.kind === 'data'} + {@render dataView( + tab.id, + tab.schema ?? ownSelected.schemaKey, + tab.table, + tab.id === tabs.currentData?.id + )} + {:else if tab.kind === 'diagram'} + {@render diagramView(tab.tables ?? [])} + {:else} + {@render sqlTab?.(tab)} + {/if} +
+ {/if} + {/each} + {:else if viewMode === 'diagram'} + {@render diagramView(diagramTables)} + {:else} + {@render dataView('', selected.schemaKey, selected.tableKey, true)} {/if} -
- {/if} - - + + + {/if} +
(aclDrawer = undefined)}> diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index b88b1d1510..8894043688 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -1,4 +1,8 @@ tabs?.update(tab.id, { code })} + onSchemaChange={() => refresh()} + schema={shownSchema} + /> + {/snippet} - - -
{ - // Only proceed if the click is directly on this div and not on the child elements - if (e.target === e.currentTarget) { - replResultData = undefined - } - }} - > - {#if replResultData} - {#key replResultData} - - {/key} - {/if} -
+ {#if !tabbed} + + +
{ + // Only proceed if the click is directly on this div and not on the child elements + if (e.target === e.currentTarget) { + replResultData = undefined + } + }} + > + {#if replResultData} + {#key replResultData} + + {/key} + {/if} +
+ {/if} + dbTableOpsFactory={({ colDefs, tableKey }) => dbTableOpsWithPreviewScripts({ colDefs, tableKey, - whereClause, input: _input, workspace: ws, tag: workerTag @@ -373,9 +429,10 @@ bind:this={_dbManager} {disabledTables} features={getDbFeatures(input)} + tableLayoutKey={(schema, table) => dbTableLayoutStorageKey(ws, _input, schema, table)} />
- {#if showRepl} + {#if showRepl && !tabbed} refresh()} - placeholderTableName={sortArray( - Object.keys( - shownSchema.schema[ - 'public' in shownSchema.schema - ? 'public' - : 'dbo' in shownSchema.schema - ? 'dbo' - : Object.keys(shownSchema.schema ?? {})?.[0] - ] ?? {} - ) - )?.[0]} + {placeholderTableName} + schema={shownSchema} /> {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index 2d4060a59c..bd016544b0 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -8,21 +8,25 @@ import Select from './select/Select.svelte' import { ArrowLeft, + Code, Copy, Download, - Expand, - Minimize, Network, + Plus, RefreshCcw, Table2, - Upload + Tag, + Upload, + X } from 'lucide-svelte' import DBManagerContent from './DBManagerContent.svelte' - import type { DbManagerViewMode, PendingRowAction } from './DBManager.svelte' - import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import DropdownV2 from './DropdownV2.svelte' + import { flip } from 'svelte/animate' + import type { PendingRowAction } from './DBManager.svelte' + import type { DbManagerTab, DbManagerTabKind } from './dbManagerTabs.svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' import { getDbType } from './dbOps' + import { renderDbQuotedIdentifier } from './apps/components/display/dbtable/utils' import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte' import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte' import { resource } from 'runed' @@ -176,12 +180,8 @@ dbManagerContent?.clearReplResult() } - let windowWidth = $state(window.innerWidth) - let expand = $state(false) - $effect(() => { if (!open) { - expand = false uriState.closeDrawer() // An action asked for on one data table must not be waiting when the // drawer is next opened on another database — or on no data table at @@ -200,20 +200,55 @@ let hasReplResult = $state(false) - // Which view the manager shows. Held here, beside the control that switches it - // and outside the key that remounts the manager, so picking another data table - // or role stays on the view the user was reading. - let requestedViewMode = $state('data') - // Only PostgreSQL has a diagram; the manager clamps the mode itself, and this - // keeps the control off the header for a database that cannot show one. + // The tabs start where the right pane does, so a narrow tree pane would run them into the + // title: below the room the title needs, in pixels, it goes. + const TITLE_ROOM_PX = 200 + let showTitle = $derived.by(() => { + const left = dbManagerContent?.dbManager()?.mainPaneLeft() + return !dbManagerContent?.tabsModel() || !left || left >= TITLE_ROOM_PX + }) + + // Only PostgreSQL has a diagram. let diagramSupported = $derived( !!uriState.effectiveInput && getDbType(uriState.effectiveInput) === 'postgresql' ) + const TAB_KINDS: { kind: DbManagerTabKind; label: string; icon: typeof Table2 }[] = [ + { kind: 'data', label: 'Data', icon: Table2 }, + { kind: 'diagram', label: 'Diagram', icon: Network }, + { kind: 'sql', label: 'SQL Editor', icon: Code } + ] + /** A plain mouse wheel scrolls vertically, which the tab strip cannot: turn it sideways. + * Registered by hand because Svelte's `onwheel` is passive and cannot prevent the default. */ + function wheelScrollsSideways(el: HTMLElement) { + const onWheel = (e: WheelEvent) => { + if (e.deltaX || !e.deltaY || el.scrollWidth <= el.clientWidth) return + el.scrollLeft += e.deltaY + e.preventDefault() + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + } + + function quotedTableName(schema: string | undefined, table: string): string { + const input = uriState.effectiveInput + if (!input) return table + const q = (name: string) => renderDbQuotedIdentifier(name, getDbType(input)) + return schema ? `${q(schema)}.${q(table)}` : q(table) + } + + let draggedTabId: string | undefined = $state() + let dropTarget: { id: string; side: 'before' | 'after' } | undefined = $state() + function tabLabel(tab: DbManagerTab): string { + if (tab.kind === 'data') return tab.table ?? 'Data' + return TAB_KINDS.find((k) => k.kind === tab.kind)!.label + } + // Export/Import state let exportDrawerOpen = $state(false) let exportResult = $state('') let importDrawerOpen = $state(false) + let workerTagOpen = $state(false) let importLoading = $state(false) let importSource = $state(undefined) /** Which database an import writes into; set when driven from a tree row. */ @@ -318,17 +353,9 @@ } - - - + { if (hasReplResult) { dbManagerContent?.clearReplResult() @@ -338,14 +365,16 @@ }} CloseIcon={hasReplResult ? ArrowLeft : undefined} noPadding + overflow_y={false} + fullScreen={false} + titleClass="text-sm text-primary" id="db-manager-drawer" > {#if contentInput && ws && roleSettled} {#key `${selectedDatatable}~${selectedRole ?? ''}`} (requestedViewMode = mode)} + tabbed input={contentInput} workspace={uriState.workspace} datatableTree={uriState.isDatatableInput ? datatables.current : undefined} @@ -357,9 +386,7 @@ uriState.selectedRole = role }} bind:pendingAction - canManageDatatable={!!($superadmin || $userStore?.is_admin) && - !!$enterpriseLicense && - !isCloudHosted()} + canManageDatatable={!!($superadmin || $userStore?.is_admin)} onDatatableAction={runDatatableAction} bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)} bind:hasReplResult @@ -375,38 +402,167 @@ > {/key} {/if} + {#snippet titleExtra()} + {@const mainPaneLeft = dbManagerContent?.dbManager()?.mainPaneLeft()} + {@const tabs = dbManagerContent?.tabsModel()} + + {#if tabs && mainPaneLeft} + +
+ {#each tabs?.tabs ?? [] as tab (tab.id)} + {@const active = tab.id === tabs?.activeId} + {@const Icon = TAB_KINDS.find((k) => k.kind === tab.kind)!.icon} + {@const dropSide = dropTarget?.id === tab.id ? dropTarget.side : undefined} + + +
{ + draggedTabId = tab.id + e.dataTransfer?.setData('text/plain', tab.id) + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' + }} + ondragover={(e) => { + if (!draggedTabId) return + e.preventDefault() + if (draggedTabId === tab.id) { + dropTarget = undefined + return + } + const rect = e.currentTarget.getBoundingClientRect() + const side = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after' + if (dropTarget?.id !== tab.id || dropTarget.side !== side) + dropTarget = { id: tab.id, side } + }} + ondrop={(e) => { + e.preventDefault() + if (draggedTabId && dropTarget) + tabs?.move(draggedTabId, dropTarget.id, dropTarget.side) + draggedTabId = undefined + dropTarget = undefined + }} + ondragend={() => { + draggedTabId = undefined + dropTarget = undefined + }} + {@attach (el) => { + if (active) el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }} + > + {#if dropSide} +
+ {/if} + + +
+ {/each} + k.kind !== 'diagram' || diagramSupported).map((k) => ({ + displayName: k.label, + icon: k.icon, + action: () => { + // A query opened from a data tab starts on that tab's table. + const from = tabs?.active.kind === 'data' ? tabs.active : undefined + tabs?.add( + k.kind, + k.kind === 'sql' && from?.table + ? { + code: `SELECT * FROM ${quotedTableName(from.schema, from.table)}` + } + : {} + ) + logFeatureUsage('db_manager', 'view_mode', { key: k.kind }) + } + }))} + btnId="db-manager-new-tab" + > + {#snippet buttonReplacement()} +
+ +
+ {/snippet} +
+
+ {/if} + {/snippet} {#snippet actions()} - - {#if enableImportExport && !uriState.isDatatableInput} - - - {/if} - {#if diagramSupported} - logFeatureUsage('db_manager', 'view_mode', { key: v })} - > - {#snippet children({ item })} - - - {/snippet} - - {/if} {#if uriState.effectiveInput && ws} - workerTag.tag, (v) => (workerTag.tag = v)} - input={uriState.effectiveInput} - workspace={ws} - /> + {@const input = uriState.effectiveInput} + +
+ [ + { + displayName: workerTag.tag ? `Worker tag: ${workerTag.tag}` : 'Worker tag', + icon: Tag, + action: () => (workerTagOpen = true) + }, + // A data table exports and imports from its row menu in the tree; a + // plain database has no tree row to hold them. + ...(enableImportExport && !uriState.isDatatableInput + ? [ + { + displayName: 'Export database', + icon: Download, + action: () => handleExportSchema() + }, + { + displayName: 'Import database', + icon: Upload, + action: () => ((importTarget = undefined), (importDrawerOpen = true)) + } + ] + : []) + ]} + btnId="db-manager-database-actions" + /> + workerTag.tag, (v) => (workerTag.tag = v)} + bind:open={workerTagOpen} + hideTrigger + {input} + workspace={ws} + /> +
{/if} - + + {/snippet} + + + {/if} + {@render hiddenColumnsNote()} +{/snippet} + +{#snippet addColumnCell()} + {#if canAddColumn} +
+ {/if} + {#if hidden.length} +
+ {/if} +{/snippet} + + { + if (editing) cancelEdit() + closeFormatOnOutsideClick(e) + }} + onkeydown={(e) => { + if (formatting && e.key === 'Escape') formatting = undefined + }} +/>
-
-
- + +
+ {#if totalRows > 0 && endRow !== 0} + + {Math.min(firstVisible + 1, totalRows)}–{Math.min(lastVisible, totalRows)} + {#if rowCount !== undefined || endRow !== undefined} + of {endRow ?? rowCount} rows + {/if} + + {/if} + + {/snippet} + {#snippet content()} +
+ +
+ {#each columns as col (col.field)} +
+ + {#if formats[col.field]} + + {/if} + openFormat(col.field) + } + ]} + size="2xs" + class="opacity-0 group-hover:opacity-100 focus-within:opacity-100" + /> +
+ {/each} +
+ {#if onNewColumn} +
+ +
+ {/if} +
+ {/snippet} + +
+ {#if dbTableOps.onInsert} + { + if (!$operatingWorkspace) return + dbTableOps.onInsert?.({ values }).then(() => { + refresh() + sendUserToast('Row inserted') + }) + }} + /> {/if}
- {#if dbTableOps.onInsert} - { - if (!$operatingWorkspace) return - dbTableOps.onInsert?.({ values }).then((result) => { - refresh?.() - sendUserToast('Row inserted') - }) - }} - /> - {/if}
-
- + +
{ - if ((e.ctrlKey || e.metaKey) && e.key === 'c') { - const selectedCell = api?.getFocusedCell() - if (selectedCell) { - const rowIndex = selectedCell.rowIndex - const colId = selectedCell.column?.getId() - const rowNode = api?.getDisplayedRowAtIndex(rowIndex) - const selectedValue = rowNode?.data?.[colId] - navigator.clipboard.writeText(selectedValue) - sendUserToast('Copied cell value to clipboard', false) + bind:this={scrollEl} + bind:clientHeight={viewportHeight} + bind:clientWidth={viewportWidth} + bind:offsetHeight={gridOuterHeight} + bind:offsetWidth={gridOuterWidth} + class={twMerge( + 'h-full w-full overflow-x-auto overflow-y-scroll relative outline-none text-xs text-primary', + // Wider than the app's default so it is easy to grab. + '[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full' + )} + tabindex="0" + role="grid" + aria-rowcount={totalRows} + data-testid="db-table-grid" + onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)} + onkeydown={onGridKeyDown} + onclick={(e) => (selected = cellFromEvent(e))} + ondblclick={(e) => { + const cell = cellFromEvent(e) + if (cell) startEdit(cell.row, cell.column) + }} + > +
+ +
+ {#each displayColumns as col (col.field)} + {@const sorted = sort?.column === col.field ? sort : undefined} + {@const pin = pinned[col.field]} + {#if col.field === firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + +
startResize(e, col.field)} + ondblclick={(e) => { + e.stopPropagation() + autoSize([col.field]) + }} + >
+
+ {/each} + {#if !firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + {#if endRow === 0} +
+ {hasFilters ? 'No rows match the filters' : 'No rows'} +
+ {/if} + +
+ {#each renderedIndices as i (i)} + {@const data = rowAt(i)} +
+ {#each displayColumns as col (col.field)} + {@const isSelected = selected?.row === i && selected.column === col.field} + {@const pin = pinned[col.field]} + {@const style = + data && data !== 'loading' + ? cellStyle(data[col.field], col.datatype, formats[col.field]) + : undefined} + {#if col.field === firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {#if pin} + +
+ {/if} + {#if data === 'loading'} +
+ {:else if data} + {@const value = data[col.field]} + {@const format = formats[col.field]} + {@const shown = formatValue(value, format) ?? formatCell(value)} + {#if value === null || value === undefined} + NULL + {:else if fkByColumn[col.field] && hasForeignKeyValue(value)} + {@const fk = fkByColumn[col.field]} + + {:else} + {shown} + {/if} + {/if} +
+ {/each} + {#if !firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {/each} +
+
+
+
+ + {#if gridOuterWidth - viewportWidth > 0} +
+ {/if} + {#if gridOuterHeight - viewportHeight > 0} +
+ {/if} +
+
+ + { + if (editing) { + const el = cellElement(editing.row, editing.column) + if (el) editorRect = el.getBoundingClientRect() + } + return editorRect + }} +> + +
e.stopPropagation()}> + {#if editing} + { + if (e.key === 'Enter') { + e.preventDefault() + commitEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + cancelEdit() } } }} - >
-
- -
-
- - {#snippet text()} - Download - {/snippet} -
- {#if rowCount} - {firstRow}{'->'}{lastRow + 1} of {rowCount} rows - {:else} - {firstRow}{'->'}{lastRow + 1} - {/if} -
+ /> +
Enter to save, Esc to cancel
+ {/if} - + + + { + if (formatting) { + const header = scrollEl?.querySelector(`[data-header-column="${CSS.escape(formatting)}"]`) + // A hidden column has no header: its format opens from the columns button. + const el = header ?? columnsAnchor + if (el) formatRect = el.getBoundingClientRect() + } + return formatRect + }} +> +
+ {#if formatting} + {@const column = formatting} + setFormat(column, f)} + /> + {/if} +
+
diff --git a/frontend/src/lib/components/DbColumnFormatEditor.svelte b/frontend/src/lib/components/DbColumnFormatEditor.svelte new file mode 100644 index 0000000000..36f98574a1 --- /dev/null +++ b/frontend/src/lib/components/DbColumnFormatEditor.svelte @@ -0,0 +1,362 @@ + + +{#snippet sample(style: { bg?: string; text?: string; bold?: boolean; italic?: boolean })} + + Aa + +{/snippet} + +{#snippet presets(onPick: (preset: (typeof RULE_PRESETS)[number]) => void)} +
+ {#each RULE_PRESETS as preset, i (i)} + + {/each} +
+{/snippet} + +
+
+
+ Format {column} + Only changes how values show in this view. +
+ +
+ +
+ Unit +
+ ({ label: String(i), value: i })) + ]} + bind:value={ + () => format?.decimals ?? AUTO_DECIMALS, + (v) => update({ decimals: v === AUTO_DECIMALS ? undefined : v }) + } + /> + format?.notation ?? 'plain', + (v) => update({ notation: v === 'plain' ? undefined : (v as 'thousands' | 'compact') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ + 1234567.891 shows as {formatValue(1234567.891, format) ?? '1234567.891'} + +
+ +
+ Alignment + format?.align ?? 'auto', + (v) => update({ align: v === 'auto' ? undefined : (v as 'left' | 'right') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ +
+ Color rules + {#each rules as rule, i (i)} + +
{ + if (dragFrom === undefined) return + e.preventDefault() + const r = e.currentTarget.getBoundingClientRect() + dropAt = { index: i, side: e.clientY < r.top + r.height / 2 ? 'above' : 'below' } + }} + ondrop={(e) => { + e.preventDefault() + dropRule() + }} + > + {#if dropAt?.index === i && dragFrom !== undefined} +
+ {/if} + +
{ + dragFrom = i + const row = e.currentTarget.parentElement + if (row) e.dataTransfer?.setDragImage(row, 8, row.offsetHeight / 2) + }} + ondragend={() => { + dragFrom = undefined + dropAt = undefined + }} + > + +
+
+ = 4, =paid' }} + bind:value={() => rule.condition, (v) => setRule(i, { condition: v })} + /> +
+ setRule(i, { bg: e.currentTarget.value })} + /> + setRule(i, { text: e.currentTarget.value })} + /> +
+ {/each} +
+ + {#snippet trigger()} + + {/snippet} + {#snippet content()} + {@render presets((preset) => { + update({ rules: [...rules, { condition: '', ...preset }] }) + addRuleOpen = false + })} + {/snippet} + +
+ + Conditions use the search bar's syntax: text contains, =value is exact, numbers take >, + >=, <, <= and !=. An empty condition matches every cell. Every matching rule applies, + a later one overriding an earlier one. + +
+
diff --git a/frontend/src/lib/components/DbForeignKeyTooltip.svelte b/frontend/src/lib/components/DbForeignKeyTooltip.svelte deleted file mode 100644 index 526d6b2b69..0000000000 --- a/frontend/src/lib/components/DbForeignKeyTooltip.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
-
- References {targetTable}.{targetColumn} -
- -
diff --git a/frontend/src/lib/components/DbSqlTab.svelte b/frontend/src/lib/components/DbSqlTab.svelte new file mode 100644 index 0000000000..a22f89c883 --- /dev/null +++ b/frontend/src/lib/components/DbSqlTab.svelte @@ -0,0 +1,63 @@ + + + + + (result = data)} + /> + + + {#if resultOps} + + {#key resultOps} + + {/key} + {:else} +
+ Run a query to see its result here +
+ {/if} +
+
diff --git a/frontend/src/lib/components/DbWorkerTagButton.svelte b/frontend/src/lib/components/DbWorkerTagButton.svelte index 3cbb60dc53..70db48b956 100644 --- a/frontend/src/lib/components/DbWorkerTagButton.svelte +++ b/frontend/src/lib/components/DbWorkerTagButton.svelte @@ -12,24 +12,40 @@ input: DbInput /** Workspace the custom tags are read from; defaults to the navigation one. */ workspace?: string + /** Render no button: the picker is opened through `open`, and pops up where this is placed. */ + hideTrigger?: boolean + open?: boolean } - let { tag = $bindable(), input, workspace = undefined }: Props = $props() + let { + tag = $bindable(), + input, + workspace = undefined, + hideTrigger = false, + open = $bindable() + }: Props = $props() let defaultTag = $derived(getDefaultDbTag(input)) - + open ?? false, (v) => (open = v)} + class={hideTrigger ? 'w-0 h-0 overflow-hidden' : undefined} + triggerAttrs={hideTrigger ? { tabindex: -1, 'aria-hidden': true } : undefined} +> {#snippet trigger()} - + {#if !hideTrigger} + + {/if} {/snippet} {#snippet content()}
diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 1bf9e7fde3..750524457e 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -4,7 +4,7 @@ import { argsToJsonPayload } from '$lib/schema' import JobLoader from '$lib/components/JobLoader.svelte' import { Button } from '$lib/components/common' - import { WindmillIcon } from '$lib/components/icons' + import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte' import { type CompletedJob, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index d0de2026cf..a49f09bb02 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -22,7 +22,6 @@ Database, Loader2 } from 'lucide-svelte' - import DucklakeResultPreview from './assets/AssetGraph/DucklakeResultPreview.svelte' import DataTestsResult from './DataTestsResult.svelte' import Portal from '$lib/components/Portal.svelte' import DisplayResultControlBar from './DisplayResultControlBar.svelte' @@ -1105,11 +1104,15 @@ /> {#if showMaterializedPreview}
- + + {#await import('./assets/AssetGraph/DucklakeResultPreview.svelte') then DucklakeResultPreview} + + {/await}
{/if}
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 6aabc30c2b..5ec3c62b5e 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -141,6 +141,9 @@ awareness?: any | undefined folding?: boolean args?: Record | undefined + /** Schema to complete SQL against when the caller already holds it; otherwise it is + * looked up from `args.database`. */ + sqlSchema?: DBSchema | undefined useWebsockets?: boolean small?: boolean scriptLang: Preview['language'] | 'bunnative' | 'tsx' | 'jsx' | 'json' | undefined @@ -191,6 +194,7 @@ awareness = undefined, folding = false, args = undefined, + sqlSchema = undefined, useWebsockets = true, small = false, scriptLang, @@ -274,7 +278,8 @@ const sessionScopedChatManager = getContext('aiChatManager') // let graphqlService: MonacoGraphQLAPI | undefined = undefined - let dbSchema: DBSchema | undefined = $state(undefined) + let fetchedDbSchema: DBSchema | undefined = $state(undefined) + let dbSchema: DBSchema | undefined = $derived(sqlSchema ?? fetchedDbSchema) let destroyed = false const uri = computeUri( @@ -777,8 +782,8 @@ async function updateSchema(newSchemaRes: string | undefined) { if (typeof newSchemaRes === 'string') { const resourcePath = newSchemaRes.replace('$res:', '') - dbSchema = $dbSchemas[resourcePath] - if (dbSchema === undefined) { + fetchedDbSchema = $dbSchemas[resourcePath] + if (fetchedDbSchema === undefined) { $dbSchemas[resourcePath] = await getDbSchemas( lang === 'graphql' ? 'graphql' : (scriptLang ?? ''), resourcePath, @@ -787,9 +792,9 @@ { customTag } ) } - dbSchema = $dbSchemas[resourcePath] + fetchedDbSchema = $dbSchemas[resourcePath] } else { - dbSchema = undefined + fetchedDbSchema = undefined } } @@ -824,6 +829,9 @@ sqlSchemaCompletor = languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.', ' ', '('], provideCompletionItems: function (model, position) { + // Registered for the whole `sql` language: every other mounted SQL editor + // would otherwise add its own schema's suggestions to this one's. + if (model !== editor?.getModel()) return { suggestions: [] } const textUntilPosition = model.getValueInRange({ startLineNumber: 1, startColumn: 1, diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index 33e8073f2f..0adb5dc690 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -1,4 +1,6 @@ diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 0994905121..c59f4266b7 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -411,6 +411,8 @@ errorIteration = 0 currentId = testId scriptProgress = undefined + notfound = false + loadError = undefined // Replay mode: feed recorded events instead of real SSE const replay = getActiveReplay() @@ -727,6 +729,9 @@ }), noLogs ) + // The completed-job branch below returns before the end of this block. + notfound = false + loadError = undefined callbacks?.change?.(job) } diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 778c1d2614..b396bd6dee 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -10,7 +10,7 @@ import Select from './select/Select.svelte' import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte' import DropdownV2 from './DropdownV2.svelte' - import { appIconComponent } from './icons' + import { appIconComponent } from './icons/appIcon.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index cac8952706..995517ef0d 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -30,7 +30,6 @@ import TextInput from '$lib/components/text_input/TextInput.svelte' import RunChart from '$lib/components/RunChart.svelte' - import JobRunsPreview from '$lib/components/runs/JobRunsPreview.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import RunsTable from '$lib/components/runs/RunsTable.svelte' @@ -50,9 +49,7 @@ import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils' - import BatchReRunOptionsPane, { - type BatchReRunOptions - } from '$lib/components/runs/BatchReRunOptionsPane.svelte' + import type { BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte' import { untrack } from 'svelte' import { page } from '$app/state' import Select from '$lib/components/select/Select.svelte' @@ -631,7 +628,9 @@ {#if selectedIds[0] === '-'}
There is no information available for this job
{:else} - + {#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview} + + {/await} {/if} {/if}
@@ -1131,27 +1130,33 @@ {:else if batchRerunOptionsIsOpen} - ( - (batchRerunOptionsIsOpen = false), - (manualSelectionMode = undefined) - )} - onConfirm={async (options) => { - await onReRunSelectedJobs(options) - }} - /> + {#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane} + ( + (batchRerunOptionsIsOpen = false), + (manualSelectionMode = undefined) + )} + onConfirm={async (options) => { + await onReRunSelectedJobs(options) + }} + /> + {/await} {:else if selectedIds.length === 1} {#if selectedIds[0] === '-'}
There is no information available for this job
{:else} - jobsLoader?.loadJobs(true, true)} - /> + + {#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview} + jobsLoader?.loadJobs(true, true)} + /> + {/await} {/if} {:else if selectedIds.length > 1}
{:else} {#if previewLayout !== 'bottom'} -
+ +