Compare commits

...

317 Commits

Author SHA1 Message Date
Ruben Fiszel
46ddbcc254 reformat package.json 2023-02-08 00:39:34 +01:00
Ruben Fiszel
fcab562bf2 update goto 2023-02-08 00:38:47 +01:00
Ruben Fiszel
56616d5c6d remove dist 2023-02-08 00:37:18 +01:00
Ruben Fiszel
471ec6b334 cleanup everything 2023-02-08 00:36:30 +01:00
Ruben Fiszel
56dd32b2f0 iife 2023-02-07 19:13:56 +01:00
Ruben Fiszel
3f8916cbc2 add MAX_RESULT_SIZE for CLOUD_HOSTED 2023-02-07 01:52:55 +01:00
Ruben Fiszel
ac991dddbc deprecate row connection in favor of connected + selectedRow 2023-02-05 20:57:47 +01:00
Ruben Fiszel
083a304645 display token fully 2023-02-05 17:28:33 +01:00
Ruben Fiszel
91491055fa add svg display result support 2023-02-05 17:15:40 +01:00
Ruben Fiszel
ae440203f0 script UX nits 2023-02-05 16:30:35 +01:00
Ruben Fiszel
ab432d628a import python code as raw to avoid reindent 2023-02-05 16:03:03 +01:00
Ruben Fiszel
e1b9247e11 use VmHwm mem instead of VmPeak for more accurate memory report 2023-02-05 15:30:02 +01:00
Ruben Fiszel
07c756f460 mem_peak use greatest 2023-02-05 14:59:31 +01:00
Ruben Fiszel
2ef6af4546 fix python default tabSize 2023-02-05 14:51:33 +01:00
Ruben Fiszel
a939771059 fix python type inference 2023-02-05 14:09:21 +01:00
Ruben Fiszel
8dc467b87a toast improvements 2023-02-05 10:12:49 +01:00
Ruben Fiszel
2ece1eb475 frontend misc 2023-02-05 10:04:26 +01:00
Ruben Fiszel
7a4da3907f frontend misc 2023-02-05 09:03:35 +01:00
Ruben Fiszel
b9d6e67791 frontend misc 2023-02-05 00:05:16 +01:00
Ruben Fiszel
f584062f13 feat: add mem peak info 2023-02-04 20:26:15 +01:00
Ruben Fiszel
265fbc5835 feat(worker): set oom_adj to 1000 to prioritize killing subprocess 2023-02-04 16:17:47 +01:00
Ruben Fiszel
2e7e57b62d update docker-compose 2023-02-04 15:50:28 +01:00
Ruben Fiszel
d17eeeecdc use unsecure websocket if unsecure connection 2023-02-04 15:50:28 +01:00
Ruben Fiszel
21c2007ebd use unsecure websocket if unsecure connection 2023-02-04 15:29:55 +01:00
Ruben Fiszel
90668902f5 avoid https on docker-compose 2023-02-04 15:20:12 +01:00
Ruben Fiszel
784aac9d1b typo 2023-02-04 01:55:05 +01:00
Ruben Fiszel
d4207db880 app checkbox nits 2023-02-04 01:50:09 +01:00
Ruben Fiszel
4ac9484305 app checkbox and text nits 2023-02-04 01:43:17 +01:00
Ruben Fiszel
0a8f177e02 chore(main): release 1.62.0 (#1170)
* chore(main): release 1.62.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-02-03 22:52:54 +01:00
Ruben Fiszel
cfa1e6f1e8 increase max concurrent runs 2023-02-03 22:39:36 +01:00
Ruben Fiszel
be526b2f23 prune args and not kwargs for function call 2023-02-03 22:37:26 +01:00
Ruben Fiszel
8bc97e0041 remove templates 2023-02-03 22:34:36 +01:00
Kai Jellinghaus
b9ac60f8bb feat: Add workspace webhook (#1158)
* Add workspace webhook

* Update SQLX

* Add webhook request histogram

* Move workspace webhook retrieval OOP

* Add removal to frontend, cache removed webhooks

* Remove WebhookUtil

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-02-03 19:50:18 +01:00
dependabot[bot]
c0a8545704 chore(deps-dev): bump svelte-preprocess from 5.0.0 to 5.0.1 in /frontend (#1164)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/sveltejs/svelte-preprocess/releases)
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-03 19:50:04 +01:00
Kai Jellinghaus
cdd16195ae feat(cli): 2-Way sync (#1071)
* Export file type from each file

* Fix example scripts

* Strongly type CLI files

* Allow bash files

* Update API version

* Remove useless files

* WIP: Diff based push

* Fixup other code

* Implement Flow diffing

* Implement resource type

* Remaining impls

* WIP

* Fix missing file error

* Fix misstyping

* Improve error message

* Fix type inferrence

* Allow REMOVE everywhere

* Fix empty changeset

* Fix error message

* Fix type inferrence 2

* Fix variable diffs

* Fix include checks

* Move push & pull

* Handle script in sync

* Handle scripts

* Allow multi-path creation

* Fix merge conflicts

* Fix #1173

* Update Dependencies

* Add missing await

* Apply review comments

* Fix diff

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-02-03 19:49:46 +01:00
Ruben Fiszel
406cba4e73 improve instructions 2023-02-03 02:19:58 +01:00
Ruben Fiszel
8d6a8386be refactor components to ease adding custom components 2023-02-03 02:11:06 +01:00
Ruben Fiszel
1a626980df revert single file components change 2023-02-03 00:00:28 +01:00
Ruben Fiszel
23007f7a71 feat: adding worker_busy 2023-02-02 23:57:53 +01:00
Ádám Kovács
9f5500c196 fix(frontend): Various fixes and improvements (#1177)
* fix(frontend): Editor bar

* fix(frontend): Keep settings tabs always visible

* fix(frontend): Keep app header on top in preview
2023-02-02 23:00:43 +01:00
Ruben Fiszel
a82a2efa6a fix nosniff html component 2023-02-02 22:10:13 +01:00
Ádám Kovács
3305481d5d feat(frontend): App initial loading animations (#1176)
* feat(frontend): App initial loading animations

* fix(frontend): Optional prop
2023-02-02 17:55:11 +01:00
Kai Jellinghaus
42691bc1bd CLI hints (#1140)
* various improvements

* passing to kai

* passing to kai

* Outline InlineCodeCopy

* Add CLI setup steps

* Workaround #1173

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-02-02 05:30:12 -08:00
Ruben Fiszel
99568eaa47 feat: add WHITELIST_WORKSPACES and BLACKLIST_WORKSPACES 2023-02-02 07:57:03 +01:00
Ruben Fiszel
68500b12b2 improve README 2023-02-02 07:01:18 +01:00
Ruben Fiszel
f171cd8b7c fix: navigate to new script page before saving script 2023-02-02 00:31:31 +01:00
Ruben Fiszel
0ca431b6cb add privacy policy and terms of service links 2023-02-01 23:10:43 +01:00
Ruben Fiszel
cb9c0846ac fix graph viewer for negative ids 2023-02-01 20:56:58 +01:00
Ádám Kovács
bc8d1a375e fix(frontend): Render popups above components in app editor (#1171)
* fix(frontend): Render popups above items in apps

* Add explanation
2023-02-01 17:52:09 +01:00
Ruben Fiszel
0921ba0085 feat: add INCLUDE_HEADERS env variable to pass value from request headers 2023-02-01 02:22:28 +01:00
Ruben Fiszel
14b32be8b2 feat(python): add ADDITIONAL_PYTHON_PATHS 2023-02-01 02:04:53 +01:00
Ruben Fiszel
b67550a790 fix nextId for components 2023-01-31 20:49:02 +01:00
dependabot[bot]
06347746c0 chore(deps-dev): bump @sveltejs/package from 1.0.1 to 1.0.2 in /frontend (#1165)
Bumps [@sveltejs/package](https://github.com/sveltejs/kit/tree/HEAD/packages/package) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/master/packages/package/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/package@1.0.2/packages/package)

---
updated-dependencies:
- dependency-name: "@sveltejs/package"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-31 20:05:16 +01:00
dependabot[bot]
0b4288ad9c chore(deps-dev): bump vite from 4.0.3 to 4.0.4 in /frontend (#1166)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.0.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-31 20:05:00 +01:00
Ruben Fiszel
a6dcb218ff ci: seperate openbb group from main 2023-01-31 20:04:20 +01:00
Ruben Fiszel
6fe93dac58 chore(main): release 1.61.1 (#1169)
* chore(main): release 1.61.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-31 20:02:29 +01:00
Ruben Fiszel
202b04bb08 fix nextId for components 2023-01-31 20:01:48 +01:00
Ruben Fiszel
df8cc1f248 fix(backend): compile issue 2023-01-31 19:50:22 +01:00
Ruben Fiszel
61e3214f9e chore(main): release 1.61.0 (#1091)
* chore(main): release 1.61.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-31 18:51:42 +01:00
Ruben Fiszel
d663884a99 more folders permission 2023-01-31 16:01:13 +01:00
Ruben Fiszel
a2ed69f87f fix demoting users 2023-01-31 15:23:58 +01:00
Ruben Fiszel
f377c84f5a feat: add sync webhook for flows 2023-01-31 10:21:48 +01:00
Ruben Fiszel
772c768cda feat(python): add support for extra args in python 2023-01-31 09:52:29 +01:00
Ruben Fiszel
a4f8f9e1cf feat(frontend): add plotly support 2023-01-31 08:04:46 +01:00
dependabot[bot]
dfae34d9b4 chore(deps): bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 (#1163)
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 1.3.5 to 1.3.6.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v1.3.5...v1.3.6)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-30 18:32:13 +00:00
Ruben Fiszel
0bc995bd07 clarify exponential backoff 2023-01-30 08:58:09 +01:00
Ruben Fiszel
161e76c500 clarify exponential backoff 2023-01-30 08:49:58 +01:00
Ruben Fiszel
c186db4e3b handle backquotes in template 2023-01-30 08:31:21 +01:00
Ruben Fiszel
652eb65082 allow whitespace in requirements override 2023-01-30 05:06:13 +01:00
Ruben Fiszel
a98ba6dc41 parseJson on resource changes 2023-01-30 03:55:01 +01:00
Ruben Fiszel
080ecb04d7 feat: add resource and resource type from json 2023-01-29 16:27:51 +01:00
Ruben Fiszel
8de3804d4a flow builder args handling improvements 2023-01-28 23:22:51 +01:00
Ruben Fiszel
008bd32ec1 fix entirecachetar path 2023-01-28 18:54:04 +01:00
Ruben Fiszel
a2f679ffc9 add support for openbb 2023-01-28 03:57:25 +01:00
Ruben Fiszel
2d3fe64bc4 add python-310 target 2023-01-28 03:34:40 +01:00
Ruben Fiszel
b7db4c78c4 feat: add PIP_LOCAL_DEPENDENCIES 2023-01-28 03:09:47 +01:00
Ruben Fiszel
d0fc40b483 grant all privilege to table favorite 2023-01-27 22:08:46 +01:00
Ruben Fiszel
1efae55d36 update stripe products references 2023-01-27 20:59:05 +01:00
Ruben Fiszel
791a772199 increase token size for refresh token 2023-01-27 18:56:42 +01:00
Ruben Fiszel
285b896a1d add stripe integration 2023-01-27 07:58:16 +01:00
Ruben Fiszel
098cd96655 add stripe integration 2023-01-27 07:29:51 +01:00
Adam Vollrath
4eee71a8fb Enable GitHub OAuth for Uffizzi Previews. (#1157)
* Enable GitHub OAuth for Uffizzi Previews.

* empty

* more specific build conditions

* Increase memory resources for `windmill` container.
2023-01-26 18:48:28 +01:00
Ádám Kovács
c01bf70f62 feat(frontend): Add HTML result rendering (#1160)
* feat(frontend): Add HTML result rendering

* fix(frontend): Add reference for XSS

* fix(frontend): Require approval only in apps

* fix(frontend): Require approval in public apps
2023-01-26 18:03:25 +01:00
Faton Ramadani
3ed16b88a4 feat(frontend): Add app inputs configurations (#1142)
* feat(frontend): Add app inputs configurations

* feat(frontend): only display resource input by default

* feat(frontend): Fix no resource field empty display

* feat(frontend): open the drawer when forked from the hub
2023-01-26 15:51:38 +01:00
Ádám Kovács
18f30c8286 fix(frontend): Update app scripts pane (#1146)
* fix(frontend): Update app scripts pane

* fix wrapping content

* import capitalize util

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-01-26 15:51:09 +01:00
Ádám Kovács
f221a6c17f fix(frontend): Add highlight to selected workspace (#1159) 2023-01-26 15:11:29 +01:00
Ruben Fiszel
51a710ffa0 do not copy necessarily for the flow object viewer 2023-01-26 03:20:01 +01:00
Ruben Fiszel
ad0c836129 add instructions for triggers on flows 2023-01-26 01:06:53 +01:00
Ruben Fiszel
be6707b461 for python change default editor tab to 4 2023-01-25 20:40:40 +01:00
Ruben Fiszel
c227ba2794 loader for flowjobresult 2023-01-25 20:33:33 +01:00
Ruben Fiszel
d60a770eb7 flow preview status viewer display more info 2023-01-25 14:14:14 +01:00
Ruben Fiszel
30d83a942c restrict number of jobs in the queue for free users 2023-01-25 13:34:08 +01:00
Ruben Fiszel
98bf9355dc implement MAX_FREE_CONCURRENT_RUNS 2023-01-25 12:58:29 +01:00
Ruben Fiszel
a3928101af worker client should use internal url 2023-01-25 00:10:37 +01:00
Ruben Fiszel
6e77757939 nodraft for editing flow from run 2023-01-24 22:57:34 +01:00
Ruben Fiszel
66c5d6e400 fix arg inputs 2023-01-24 22:44:52 +01:00
Ruben Fiszel
b0c184767c update pricing page 2023-01-24 21:58:44 +01:00
Ruben Fiszel
1165cf6e24 UX improvements for flows 2023-01-24 21:53:41 +01:00
Ruben Fiszel
a9640107f3 background scripts now part of the policy 2023-01-24 15:09:27 +01:00
Faton Ramadani
73bc13bb7d feat(frontend): Open debug runs from component (#1155)
* feat(frontend): Add debug runs

* feat(frontend): Fix build

* feat(frontend): revert implementation

* feat(frontend): Fix error displa

* feat(frontend): Fix error display

* feat(frontend): Fix preview mode

* feat(frontend): Fix error display
2023-01-24 14:34:19 +01:00
Ádám Kovács
e98966283d fix(frontend): App table action button cell (#1149)
Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-01-24 12:20:27 +01:00
Ruben Fiszel
8ad2344ec6 improve cancel approval error message 2023-01-24 12:11:39 +01:00
Ruben Fiszel
b77c239f30 fix: improvements for error handling as first step of flow 2023-01-24 11:16:44 +01:00
Ruben Fiszel
15cb83936b error handler improvements 2023-01-24 07:30:07 +01:00
Ruben Fiszel
e2157ee1c0 fix listing runs from schedule 2023-01-24 06:55:11 +01:00
dependabot[bot]
bdfd50b970 chore(deps): bump @leeoniya/ufuzzy from 0.9.0 to 0.9.1 in /frontend (#1153)
Bumps [@leeoniya/ufuzzy](https://github.com/leeoniya/uFuzzy) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/leeoniya/uFuzzy/releases)
- [Commits](https://github.com/leeoniya/uFuzzy/compare/0.9.0...0.9.1)

---
updated-dependencies:
- dependency-name: "@leeoniya/ufuzzy"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-24 04:04:34 +01:00
dependabot[bot]
4debfd68dc chore(deps-dev): bump @typescript-eslint/eslint-plugin in /frontend (#1151)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 5.46.1 to 5.49.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v5.49.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-24 04:03:59 +01:00
dependabot[bot]
410032060a chore(deps): bump chartjs-adapter-date-fns in /frontend (#1154)
Bumps [chartjs-adapter-date-fns](https://github.com/chartjs/chartjs-adapter-date-fns) from 2.0.0 to 3.0.0.
- [Release notes](https://github.com/chartjs/chartjs-adapter-date-fns/releases)
- [Commits](https://github.com/chartjs/chartjs-adapter-date-fns/compare/v2.0.0...v3.0.0)

---
updated-dependencies:
- dependency-name: chartjs-adapter-date-fns
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-24 04:03:32 +01:00
Faton Ramadani
22b8fed9d9 feat(frontend): Display error as an icon in order to avoid clutter wh… (#1143)
* feat(frontend): Display error as an icon in order to avoid clutter when an error occures

* feat(frontend): Fix build

* feat(frontend): Fix error icon position
2023-01-23 18:03:31 +01:00
Ruben Fiszel
0172587b12 feat: add sql as a valid type in Python 2023-01-23 15:58:33 +01:00
Faton Ramadani
f05f9e4edb feat(frontend): add selectedRowIndex to the table outputs (#1145) 2023-01-23 15:39:15 +01:00
Faton Ramadani
24b1fa0ae3 fix(frontend): Make sure AppSelect items are an array (#1144)
* fix(frontend): Make sure AppSelect items are an array

* fix(frontend): restore placeholder
2023-01-23 05:37:38 -08:00
Ruben Fiszel
fedb9fe74c update pricing page 2023-01-22 18:34:05 +01:00
Ruben Fiszel
5408da829c update pricing page 2023-01-22 18:20:06 +01:00
Ruben Fiszel
448e45b7e4 fix privilege on cloud 2023-01-22 00:53:29 +01:00
Ruben Fiszel
156f257619 add import_map to nsjail 2023-01-21 23:28:11 +01:00
Ruben Fiszel
7f40373fd6 feat(deno): allow overriding deno sandboxing with DENO_FLAGS' 2023-01-21 22:54:14 +01:00
Ruben Fiszel
f2ec03ba1b implement relative imports for deno 2023-01-21 19:12:22 +01:00
Ruben Fiszel
9a79c5537e fix compile 2023-01-21 18:49:26 +01:00
Ruben Fiszel
cf156b0591 support BigInt serialization 2023-01-21 18:41:58 +01:00
Ruben Fiszel
832ddabdf2 feat(deno): add support for DENO_AUTH_TOKENS 2023-01-21 18:36:17 +01:00
Ruben Fiszel
22080312f0 description at bottom 2023-01-21 17:33:23 +01:00
Ruben Fiszel
165095376d description at bottom 2023-01-21 10:19:05 +01:00
Ruben Fiszel
2a816b9e7b remove square from oauth 2023-01-21 09:37:10 +01:00
Ruben Fiszel
a7d03f4e25 square use req_body_auth 2023-01-21 09:26:39 +01:00
Ruben Fiszel
5e18e580f7 fix superadmins permissions 2023-01-21 08:09:49 +01:00
Felipe Albertao
9b7906e43d Added Square API OAuth (#1141) 2023-01-21 07:41:52 +01:00
Ruben Fiszel
2143bbdc94 add support for python import shopifyAPI 2023-01-20 19:52:18 +01:00
Ruben Fiszel
51a8810aa0 feat: add QUEUE_LIMIT_WAIT_RESULT 2023-01-20 19:39:31 +01:00
Faton Ramadani
8f906026b3 fix(frontend): Add default value for text, number and date input + fix issues with number input + add date input in the settings panel (#1135)
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-01-20 18:31:10 +01:00
Faton Ramadani
aa6de3bb57 fix(frontend): Prevent modal from hijacking all keypress event (#1136) 2023-01-20 15:39:37 +01:00
Faton Ramadani
71a443e3c5 fix(frontend): Check if hiddenInlineScripts are undefined before iterating over them (#1134) 2023-01-20 11:49:43 +01:00
Ruben Fiszel
d1526d9c72 fix deno nsjail vmem 2023-01-20 11:49:08 +01:00
Ruben Fiszel
2456e35908 install nodejs only on heavy 2023-01-19 21:01:30 +01:00
Ruben Fiszel
f359015d89 bump default bash tmp size 2023-01-19 20:32:08 +01:00
Ruben Fiszel
b8155f4d27 update npm used in heavy 2023-01-19 20:00:01 +01:00
Ruben Fiszel
2ce903d7e7 add more details about signals termination 2023-01-19 18:20:24 +01:00
Ruben Fiszel
d4765dee16 add nodejs npm to heavy image 2023-01-19 16:22:33 +01:00
Faton Ramadani
a78486d7e0 fix(frontend): Make sure that old apps are rendering properly (#1132) 2023-01-19 15:14:15 +01:00
Faton Ramadani
e969af9e44 feat(frontend): add support for background scripts + add FormButtonCo… (#1124)
* feat(frontend): add support for background scripts + add FormButtonComponnet

* feat(frontend): add background script fields

* feat(frontend): Handle deletion + display of results

* feat(frontend): Fix background script ids: Runnable ids needs to be valid js expression, because they are used in the evaluator.

* feat(frontend): add a portal to display modal

* feat(frontend): fix build

* feat(frontend): Add missing props

* feat(frontend): Fix run button + add tooltip + fix icon
2023-01-19 14:52:32 +01:00
Ruben Fiszel
e371fda1ce relax nsjail limits 2023-01-19 14:51:43 +01:00
Ruben Fiszel
ee14bed3d9 relax nsjail limits 2023-01-19 14:35:06 +01:00
Faton Ramadani
3d873ed51c fix(frontend): add missing condition to properly select first row (#1128) 2023-01-19 14:08:32 +01:00
Ruben Fiszel
d85f550611 handle empty flow module input transforms 2023-01-19 12:22:37 +01:00
Faton Ramadani
6c9daf7002 fix(frontend): fix first row selection (#1125)
* fix(frontend): fix first row selection

* fix(frontend): fix order of conditions
2023-01-19 11:52:34 +01:00
Ádám Kovács
6a88e8c4f4 feat(frontend): Add app preview lock (#1127)
* feat(frontend): Add locked state to app preview

* change color

* add transition
2023-01-19 11:27:16 +01:00
Ruben Fiszel
99246382ee minor UI fixes 2023-01-18 17:56:40 +01:00
Ruben Fiszel
73e1018f65 minor UI fixes 2023-01-18 17:08:34 +01:00
Ruben Fiszel
f1157e3be2 remove vega lite actions 2023-01-18 16:06:56 +01:00
Ruben Fiszel
bd79938bed feat(frontend): add vega-lite component 2023-01-18 15:49:47 +01:00
Ádám Kovács
173093a403 fix(frontend): Prepare app scripts code for export (#1123) 2023-01-18 12:43:24 +01:00
Ruben Fiszel
ed61df6d8a UX nits 2023-01-18 11:30:08 +01:00
Ruben Fiszel
24feb64dfe fix reporting duration above 60s 2023-01-18 11:23:25 +01:00
Ruben Fiszel
b3eaf86e40 HTMLComponent refresh on content change 2023-01-18 10:24:29 +01:00
Ádám Kovács
4dfdf374af feat(frontend): Add tooltip to app recompute (#1122) 2023-01-18 10:10:47 +01:00
Ruben Fiszel
45191621d2 UX nits on schedule 2023-01-18 09:42:49 +01:00
Ruben Fiszel
4d60be738e UX nits on schedule 2023-01-18 09:38:54 +01:00
Ruben Fiszel
eef3bab6e4 feat(backend): add queue_limit + configurable timeout + fix timeout cancel 2023-01-18 09:02:38 +01:00
Ruben Fiszel
14ebb6dc32 fix all cases where schema has been set to undefined 2023-01-18 06:56:44 +01:00
Ruben Fiszel
9e602691b6 do not cancel the job on leaving the preview page 2023-01-18 00:31:55 +01:00
Ruben Fiszel
02045001d2 do not cancel the job on leaving the preview page 2023-01-18 00:14:29 +01:00
Faton Ramadani
3c483f5337 fix(frontend): Select the first row by default, and remove the abilit… (#1121)
* fix(frontend): Select the first row by default, and remove the ability to deselect + fix table header z-index

* fix(frontend): restore z-index, under the floating tags
2023-01-17 19:10:06 +01:00
Ruben Fiszel
bcb5c3697d inline tailwindcss for html component in apps 2023-01-17 17:48:40 +01:00
Ruben Fiszel
0e50579089 frontend(apps): html component uses iframe for security 2023-01-17 17:29:01 +01:00
Ádám Kovács
59c4cc2058 fix(frontend): App table header z-index (#1120) 2023-01-17 14:00:54 +01:00
Ádám Kovács
462adbe42f fix(frontend): Runnable table overflow (#1119) 2023-01-17 12:51:14 +01:00
Ruben Fiszel
d708b6faa5 fix permissioned_as from username 2023-01-17 12:49:24 +01:00
Ruben Fiszel
40d566ab8e remove unecessary badge width 2023-01-17 12:32:30 +01:00
Ruben Fiszel
c5d712c977 ci: build both amd and arm on same workflow leveraging depot 2023-01-17 12:16:36 +01:00
Ádám Kovács
631a3da17f fix(frontend): Show app builder header always on top (#1118)
* fix(frontend): Keep app header always on top

* various fixes

* fix
2023-01-17 12:02:21 +01:00
Ruben Fiszel
ff5145d7bd UI nits 2023-01-17 12:00:17 +01:00
Ruben Fiszel
d1131c1a16 various fixes 2023-01-17 11:30:37 +01:00
Ruben Fiszel
9b89df9ecf various fixes 2023-01-17 11:30:22 +01:00
Ruben Fiszel
acff87a09f all 2023-01-17 08:09:46 +01:00
Ruben Fiszel
a182ce2d2c all 2023-01-17 08:09:39 +01:00
Ruben Fiszel
0954b5a4f2 ci: npm run check generate backend client 2023-01-17 07:40:40 +01:00
Ruben Fiszel
d297223fa5 ci: npm check on frontend change and uffizzi only on backend changes 2023-01-17 07:38:05 +01:00
dependabot[bot]
9bccb90c7a chore(deps-dev): bump prettier from 2.8.1 to 2.8.3 in /frontend (#1111)
Bumps [prettier](https://github.com/prettier/prettier) from 2.8.1 to 2.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.8.1...2.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-01-17 07:17:42 +01:00
dependabot[bot]
6403259e2a chore(deps): bump @tanstack/svelte-table in /frontend (#1112)
Bumps [@tanstack/svelte-table](https://github.com/tanstack/table) from 8.7.2 to 8.7.6.
- [Release notes](https://github.com/tanstack/table/releases)
- [Commits](https://github.com/tanstack/table/compare/v8.7.2...v8.7.6)

---
updated-dependencies:
- dependency-name: "@tanstack/svelte-table"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-01-17 06:53:11 +01:00
Ruben Fiszel
fa367f4128 point uffizzi lsp to 60d image 2023-01-17 06:51:17 +01:00
Ruben Fiszel
164f281dd0 fix uffizzi preview 2023-01-17 06:46:37 +01:00
Adam Vollrath
760cb31bae Use Uffizzi workflow v2 and other minor improvements. (#1114)
* Use Uffizzi workflow `v2` and other minor improvements.

* Specify Uffizzi Preview URL as target for `playwright`.

Answers https://github.com/windmill-labs/windmill/pull/1106#issuecomment-1384334128

* recheck
2023-01-16 20:32:46 +01:00
Faton Ramadani
4f869811fe fix(frontend): Fix id generation when a second action (#1110) 2023-01-16 19:07:22 +01:00
Ruben Fiszel
0f26a3b111 playwright against uffizzi (#1109)
* all

* fix perms

* move build to depot

* login

* revert to docker build

* revert to docker build

* fix playwright
2023-01-16 18:01:45 +01:00
Vibhav Bobade
fadae2d2b7 create preview environments for every PR using Uffizzi (#1106)
* Integrate Uffizzi

* remove lsp build

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-01-16 17:03:31 +01:00
Faton Ramadani
12b00a808d feat(frontend): Add Supabase resource (#1107)
* feat(frontend): Add Supabase resource

* feat(frontend): Added an image
2023-01-16 16:54:06 +01:00
Ádám Kovács
f0435f5f81 fix(frontend): Playwright (#1108)
* fix(frontend): Playwright

* fix(frontend): Update test base url

* feat(ci): Add base url to playwright
2023-01-16 16:51:14 +01:00
Ruben Fiszel
f018d025a2 fix schedule permissions 2023-01-16 16:42:42 +01:00
Ruben Fiszel
5d720e96e6 add row to eval input 2023-01-16 13:23:31 +01:00
Ruben Fiszel
e1b77b0d5f migrate more builds to depot.dev 2023-01-16 10:56:20 +01:00
Ruben Fiszel
2db6424f9d add dynamic disabled field to button 2023-01-16 01:52:18 +01:00
Ruben Fiszel
9a58145f72 add selenium-wire exception 2023-01-15 22:48:49 +01:00
Kyle Galbraith
51c1ae937c add packages: write for ghcr (#1105) 2023-01-15 18:29:18 +01:00
Ruben Fiszel
26bd9ac9aa remove comingsoon for UIs 2023-01-15 17:58:40 +01:00
Kyle Galbraith
3ba28b8156 Switch to Depot for Arm image build (#1104)
* switch to Depot for native arm builds without emulation

* set Depot project id
2023-01-15 17:55:25 +01:00
Ruben Fiszel
3f318b4ac4 add license key check 2023-01-15 12:06:37 +01:00
Ruben Fiszel
452c6de7e3 script_helpers nit 2023-01-15 08:32:37 +01:00
Ruben Fiszel
b1a86db58b nit error handling 2023-01-15 07:22:35 +01:00
Ruben Fiszel
b29eccf4bb : 2023-01-15 01:39:07 +01:00
Ruben Fiszel
ba28f6c62a error improvements 2023-01-15 01:28:13 +01:00
Ruben Fiszel
279f671dcf improve default connection for failure module 2023-01-14 19:22:59 +01:00
Ruben Fiszel
954cc1bb95 fix tests wrt to new error handling 2023-01-14 18:47:37 +01:00
Ruben Fiszel
776d4c9ca1 error handling improvements 2023-01-14 17:46:03 +01:00
Ruben Fiszel
6949516e63 improve error handling 2023-01-14 15:08:47 +01:00
Ruben Fiszel
f6c13b0324 avoid annoying panel resizing on value change 2023-01-14 14:29:12 +01:00
Ádám Kovács
d811675882 feat(frontend): Add surreal db logo (#1102) 2023-01-14 14:17:43 +01:00
Ruben Fiszel
735aee952a add hub compatible json export to apps 2023-01-14 09:10:39 +01:00
Ruben Fiszel
9432f4c1fa minor tooltip fix 2023-01-14 07:45:15 +01:00
Ruben Fiszel
5bf5a4f86e sqlx fix 2023-01-13 22:27:18 +01:00
Ruben Fiszel
60ef8f569b run_wait_result improvements: cancel + early tx drop 2023-01-13 21:30:29 +01:00
Ádám Kovács
7bc59d9d26 fix(frontend): App script list panel overflow (#1101) 2023-01-13 20:24:18 +01:00
Ruben Fiszel
bd4c7b7957 minor improvements 2023-01-13 18:52:34 +01:00
Ádám Kovács
172b5dba8f feat(frontend): Update app table component styles (#1100)
* feat(frontend): Update app table component styles

* feat(frontend): Update table search style
2023-01-13 18:44:21 +01:00
Ruben Fiszel
b478ba8c45 clarify run detail page + allow i64 for MAX_LOG_SIZE 2023-01-13 18:39:17 +01:00
Ruben Fiszel
71708b7566 add MAX_LOG_SIZE env params 2023-01-13 14:46:07 +01:00
Ádám Kovács
c50c7406f2 feat(frontend): Add default codes to app editor (#1099) 2023-01-13 13:48:28 +01:00
Ruben Fiszel
00357261d5 add psycopg-pool to psycopg default install 2023-01-13 12:16:48 +01:00
Ruben Fiszel
c121908716 fix table static exports 2023-01-13 12:08:37 +01:00
Ádám Kovács
2191e85231 feat(frontend): Add more integration icons (#1097)
* feat(frontend): Add more integration icons

* fix

* fix
2023-01-13 08:48:31 +01:00
Ruben Fiszel
a85a964819 add psycopg3 to the list of support imports 2023-01-13 07:54:05 +01:00
Ruben Fiszel
2ee418b6bf add polars to lsp 2023-01-12 23:12:07 +01:00
Ruben Fiszel
8f72c32fbe various improvements 2023-01-12 22:20:54 +01:00
Ruben Fiszel
1cc5059179 fix execute_component tokens 2023-01-12 16:53:54 +01:00
Ruben Fiszel
9280a5132e minor app fixes 2023-01-12 13:04:17 +01:00
Ruben Fiszel
a626aa6175 minor app fixes 2023-01-12 12:51:27 +01:00
Ádám Kovács
339742ca77 fix(frontend): App form component display (#1096) 2023-01-12 11:41:05 +01:00
Ádám Kovács
43e069eb96 fix(frontend): Use absolute path on connect images (#1095) 2023-01-12 11:40:41 +01:00
Ruben Fiszel
8e1fecfda0 make openapi viewer rely on local assets only 2023-01-11 21:02:53 +01:00
Ruben Fiszel
3e1c9463d6 make openapi viewer rely on local assets only 2023-01-11 20:41:52 +01:00
Ruben Fiszel
1337811438 feat: add openapi viewer (#1094)
* all

* all

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix typecheck

* fix tests

* fix tests

* fix tests

* all
2023-01-11 20:28:50 +01:00
Ruben Fiszel
a028772bf4 fix typecheck 2023-01-11 17:21:02 +01:00
Ádám Kovács
0419e7e1c9 fix(frontend): Revert component input panel change (#1092) 2023-01-11 16:56:58 +01:00
Ádám Kovács
bdfc38d954 feat(frontend): Add copy button option to app text display component (#1090)
* feat(frontend): Add copy button to app text

* feat(frontend): Add tooltip to component configs

* fix(frontend): Remove copy button text
2023-01-11 16:05:19 +01:00
Ruben Fiszel
dbd632375c fix backend tests 2023-01-11 14:56:05 +01:00
Ruben Fiszel
2dfbf84d62 chore(main): release 1.60.0 (#1086)
* chore(main): release 1.60.0

* Update CHANGELOG.md

* Apply automatic changes

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-11 12:14:48 +01:00
Ádám Kovács
cb25f88300 fix(frontend): Keep pane resizer under open drawer (#1089) 2023-01-11 12:06:05 +01:00
Ádám Kovács
3cbe5d1f66 fix(frontend): Remove comment remnant (#1088) 2023-01-11 11:38:55 +01:00
Ádám Kovács
e93c511bb5 fix(frontend): App form component (#1087)
* fix(frontend): Make app form usage more clear

* fix(frontend): Form orientation

* fix(frontend): Form message format

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-01-11 11:24:46 +01:00
Ruben Fiszel
6bc0320bff add Use as template for apps 2023-01-11 11:00:10 +01:00
Ruben Fiszel
3418a07c45 various app fixes 2023-01-11 10:57:30 +01:00
Ruben Fiszel
c0df9a5e20 update axum 2023-01-11 08:19:20 +01:00
Ruben Fiszel
b4e9468461 update change-versions 2023-01-11 04:54:55 +01:00
Ruben Fiszel
a14623feaa feat: add 'add user to workspace' 2023-01-11 00:51:04 +01:00
Ádám Kovács
342ea5609b fix(frontend): App minor fixes (#1085)
* fix(frontend): Tooltip contrast

* fix(frontend): App button padding
2023-01-10 22:53:11 +01:00
Ádám Kovács
8e72007368 feat(frontend): Add frost to color palette (#1084) 2023-01-10 22:52:37 +01:00
Ruben Fiszel
48d87ab12c various improvements 2023-01-10 11:21:16 +01:00
dependabot[bot]
4e0c2d06fc chore(deps-dev): bump simple-svelte-autocomplete in /frontend (#1083)
Bumps [simple-svelte-autocomplete](https://github.com/pstanoev/simple-svelte-autocomplete) from 2.5.0 to 2.5.1.
- [Release notes](https://github.com/pstanoev/simple-svelte-autocomplete/releases)
- [Commits](https://github.com/pstanoev/simple-svelte-autocomplete/compare/v2.5.0...v2.5.1)

---
updated-dependencies:
- dependency-name: simple-svelte-autocomplete
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 20:00:03 +01:00
Ruben Fiszel
cbddd6b98b chore: update svelte-check 2023-01-09 19:59:17 +01:00
dependabot[bot]
35297d4b52 chore(deps): bump svelte-select from 5.0.1 to 5.0.2 in /frontend (#1081)
Bumps [svelte-select](https://github.com/rob-balfre/svelte-select) from 5.0.1 to 5.0.2.
- [Release notes](https://github.com/rob-balfre/svelte-select/releases)
- [Changelog](https://github.com/rob-balfre/svelte-select/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rob-balfre/svelte-select/compare/v5.0.1...v5.0.2)

---
updated-dependencies:
- dependency-name: svelte-select
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 18:54:41 +00:00
Ruben Fiszel
2c717e00e5 fix computing policy prefix for hub scripts 2023-01-09 19:46:24 +01:00
dependabot[bot]
3bc1e6248b chore(deps): bump @leeoniya/ufuzzy from 0.8.0 to 0.9.0 in /frontend (#1080)
Bumps [@leeoniya/ufuzzy](https://github.com/leeoniya/uFuzzy) from 0.8.0 to 0.9.0.
- [Release notes](https://github.com/leeoniya/uFuzzy/releases)
- [Commits](https://github.com/leeoniya/uFuzzy/compare/0.8.0...0.9.0)

---
updated-dependencies:
- dependency-name: "@leeoniya/ufuzzy"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 18:30:33 +00:00
Ruben Fiszel
2e2067fe13 compute policy also for action buttons 2023-01-09 19:24:16 +01:00
dependabot[bot]
367903e359 chore(deps-dev): bump @playwright/test in /frontend (#1079)
Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.29.1 to 1.29.2.
- [Release notes](https://github.com/Microsoft/playwright/releases)
- [Commits](https://github.com/Microsoft/playwright/compare/v1.29.1...v1.29.2)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 18:23:48 +00:00
dependabot[bot]
37f2de6196 chore(deps-dev): bump svelte-dnd-action in /frontend (#1076)
Bumps [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) from 0.9.21 to 0.9.22.
- [Release notes](https://github.com/isaacHagoel/svelte-dnd-action/releases)
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

---
updated-dependencies:
- dependency-name: svelte-dnd-action
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 18:21:24 +00:00
Ruben Fiszel
5505d0a7c9 fix infering schema for inlineeditor 2023-01-09 18:57:30 +01:00
Ruben Fiszel
c61f9d4d66 fix infering schema for inlineeditor 2023-01-09 18:46:59 +01:00
Ruben Fiszel
18946918c3 also update schema when moving flow 2023-01-09 17:26:45 +01:00
Ruben Fiszel
e676c18b5f fix approval page approve endpoint empty payload 2023-01-09 17:19:56 +01:00
Ruben Fiszel
a9cce15b0c nit: close drawer when connecting oauth in new window 2023-01-09 16:52:05 +01:00
Ruben Fiszel
eb49dd8216 nits: clarify resume messages 2023-01-09 16:46:36 +01:00
Ruben Fiszel
e2f2db2d7f edit operatorOnly on toggle change 2023-01-09 16:42:02 +01:00
Ruben Fiszel
6e9c61a7fb (frontend) remove duplicate of folders from folder list name 2023-01-09 16:14:38 +01:00
Ruben Fiszel
4d5acf6f52 creator of group is also automatically a member 2023-01-09 16:08:10 +01:00
Ruben Fiszel
55e2bf9fce bump backend versions 2023-01-09 15:35:28 +01:00
Ruben Fiszel
daf8e2e8be fix backend tests 2023-01-09 14:51:27 +01:00
Ruben Fiszel
b5b13518da chore(main): release 1.59.0 (#1074)
* chore(main): release 1.59.0

* Apply automatic changes

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-09 14:03:36 +01:00
Ruben Fiszel
d3e2cf0779 fix backend tests 2023-01-09 14:00:17 +01:00
Ruben Fiszel
5347cd46a9 feat: add relative imports for python scripts (#1075)
* all

* all
2023-01-09 13:49:30 +01:00
Ruben Fiszel
32d90d4f18 add svelte as peer dep 2023-01-09 11:48:38 +01:00
Ruben Fiszel
1076c4ffc1 bump versions 2023-01-09 11:46:30 +01:00
Ádám Kovács
a84eb9b1f7 fix(frontend): Iconed resource height issue (#1073) 2023-01-09 11:44:16 +01:00
Ruben Fiszel
9540e6cec3 add appwrite and linkding logos 2023-01-07 19:16:41 +01:00
Ruben Fiszel
54ef5a6d87 chore(main): release 1.58.0 (#1061)
* chore(main): release 1.58.0

* Apply automatic changes

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-07 17:00:46 +08:00
Ruben Fiszel
6edf9b9946 feat: add archive/unarchive/delete workspace 2023-01-07 09:39:50 +01:00
Ruben Fiszel
0bcdcaedcf feat: add min/max constraint to number + slider component 2023-01-07 08:51:09 +01:00
Ruben Fiszel
50453ca690 feat: add hub support for apps 2023-01-07 07:44:44 +01:00
Faton Ramadani
b5edb7ed98 App UI improvements (#1072)
* fix(frontend): Improve App UI

* fix(frontend):Align typing

* fix(frontend): Improve App UI

* fix(frontend): remove debug string

* fix(frontend): remove debug string

* fix(frontend): restore code from main
2023-01-06 17:46:45 +01:00
Ruben Fiszel
099c4fd10d add debug Runs to apps 2023-01-05 08:54:18 +01:00
Ruben Fiszel
3fba91084d improve job loader in case of concurrent requests 2023-01-05 05:57:22 +01:00
Ruben Fiszel
aaac41761c ensuring results come-in order for testjobloader 2023-01-04 21:01:40 +01:00
Ruben Fiszel
1967413ad4 remove early return executeComponent 2023-01-04 20:24:01 +01:00
Ruben Fiszel
7aa4751531 fix some apps' components default sizing 2023-01-04 20:05:55 +01:00
Ruben Fiszel
37a8789741 remove spurrious console logs 2023-01-04 19:55:31 +01:00
Ruben Fiszel
3e9a6ebc5a hide spurrious Unexpected usage 2023-01-04 19:03:31 +01:00
Ruben Fiszel
93ce9c42fe fix overflow on component object viewer 2023-01-04 18:55:33 +01:00
Ruben Fiszel
7437dc00aa apps improvements 2023-01-04 18:37:42 +01:00
Ruben Fiszel
47b498da80 fix flow input transform default bug 2023-01-04 15:57:19 +01:00
Ruben Fiszel
1c75bf7bef add more apps components 2023-01-04 15:54:23 +01:00
Ruben Fiszel
ad1ffdb613 fix template editor monaco workers 2023-01-04 10:56:15 +01:00
Ruben Fiszel
5204e4a75d feat: add support for yaml format as a string format 2023-01-04 10:37:13 +01:00
Ruben Fiszel
aaaece99e7 apps UX nits 2023-01-04 10:14:03 +01:00
Ruben Fiszel
03da654714 improve draft logic for scripts/apps/flows 2023-01-04 07:22:30 +01:00
Ruben Fiszel
43a7d9d7b4 apps UX nits 2023-01-04 05:35:01 +01:00
Ruben Fiszel
68a09176d3 add run button directly in inline editor 2023-01-04 05:27:47 +01:00
Ruben Fiszel
8958118bef fix apps UX nits 2023-01-04 05:07:18 +01:00
Ruben Fiszel
82b30f4da6 fix forking hub deno scripts in apps 2023-01-03 15:56:02 +01:00
Ruben Fiszel
2acb7d1774 nit fix incorrect overflow-auto in display result 2023-01-03 11:17:15 +01:00
Ruben Fiszel
976c3d49dd add context to context panel in apps 2023-01-03 10:41:51 +01:00
Ruben Fiszel
fa522da565 apps improvements 2023-01-03 09:46:36 +01:00
Ruben Fiszel
8517c25153 disallow loading public app if not visible anymore 2023-01-03 03:29:35 +01:00
dependabot[bot]
c2a733dc42 chore(deps-dev): bump eslint-config-prettier in /frontend (#1068)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.5.0 to 8.6.0.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.5.0...v8.6.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 18:20:05 +00:00
dependabot[bot]
d8feaf341a chore(deps-dev): bump @typescript-eslint/parser in /frontend (#1067)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 5.46.1 to 5.48.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v5.48.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 18:13:16 +00:00
dependabot[bot]
4c25db3891 chore(deps-dev): bump @types/node from 18.11.9 to 18.11.18 in /frontend (#1066)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 18.11.9 to 18.11.18.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 18:13:00 +00:00
dependabot[bot]
b0c7d9af27 chore(deps-dev): bump @playwright/test in /frontend (#1065)
Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.28.1 to 1.29.1.
- [Release notes](https://github.com/Microsoft/playwright/releases)
- [Commits](https://github.com/Microsoft/playwright/compare/v1.28.1...v1.29.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 18:12:40 +00:00
Ádám Kovács
76a860fe53 fix(frontend): Remove popover hover styles (#1064) 2023-01-02 19:05:29 +01:00
Ruben Fiszel
5de7dc9248 further public apps improvements 2023-01-02 19:02:40 +01:00
Ádám Kovács
45acb89f87 feat(frontend): Add integration icons (#1063) 2023-01-02 18:54:01 +01:00
Ádám Kovács
34e3331919 feat(frontend): Update app auto-refresh button (#1062)
* feat(frontend): Update interval selector

* fix(frontend): Interval

* feat(frontend): Resource saving

* fix(frontend): Update selector labels

* label
2023-01-02 18:53:47 +01:00
Ruben Fiszel
39e7fc6c28 tighten public apps security using triggerable policy 2023-01-02 18:53:10 +01:00
Ruben Fiszel
39e2bf39e9 make app public page not require login at all 2023-01-02 13:02:45 +01:00
Ádám Kovács
4d6a568820 feat(frontend): Toggle to hide optional inputs (#1060) 2023-01-02 10:09:33 +01:00
Ruben Fiszel
a34ae8b2ae improve apps wiring 2023-01-02 10:07:17 +01:00
Ruben Fiszel
c7e60843d4 improve apps wiring 2023-01-02 10:07:15 +01:00
Ruben Fiszel
160dfa3121 chore(main): release 1.57.1 (#1059)
* chore(main): release 1.57.1

* Apply automatic changes

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-01-02 04:45:09 +01:00
Ruben Fiszel
2c8e98a9c7 fix: preserver order changes for flows' schema 2023-01-02 04:43:28 +01:00
Ruben Fiszel
334ec1a084 remove buggy transition fade on code editor 2023-01-02 04:28:23 +01:00
Ruben Fiszel
ab0aeb0df8 fix: support setting undefined states 2023-01-02 04:22:56 +01:00
381 changed files with 18054 additions and 6116 deletions

5
.github/uffizzi/caddy/Caddyfile vendored Normal file
View File

@@ -0,0 +1,5 @@
localhost {
bind 0.0.0.0
reverse_proxy /ws/* http://0.0.0.0:3001
reverse_proxy /* http://0.0.0.0:8000
}

View File

@@ -0,0 +1,53 @@
version: '3.7'
x-uffizzi:
ingress:
service: windmill
port: 8000
services:
db:
image: postgres:14
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
windmill:
image: '${WINDMILL_IMAGE}'
ports:
- 8000:8000
entrypoint: ['/bin/sh', '-c']
command: 'echo ${OAUTH_JSON_BASE64} | base64 --decode > /usr/src/app/oauth.json && ./windmill'
environment:
- DATABASE_URL=postgres://postgres:changeme@localhost/windmill?sslmode=disable
- BASE_URL=${EXPECTED_URL}
- BASE_INTERNAL_URL=http://localhost:8000
- RUST_LOG=info
- NUM_WORKERS=3
- KEEP_JOB_DIR=false
- DENO_PATH=/usr/bin/deno
- PYTHON_PATH=/usr/local/bin/python3
- METRICS_ADDR=false
- OAUTH_JSON_BASE64=${OAUTH_JSON_BASE64}
volumes:
- worker_dependency_cache:/tmp/windmill/cache
deploy:
resources:
limits:
memory: 250M
lsp:
image: '${LSP_IMAGE}'
ports:
- 3001:3001
# caddy:
# image: caddy:2.5.2-alpine
# restart: unless-stopped
# volumes:
# - ./.github/uffizzi/caddy:/etc/caddy
# environment:
# - BASE_URL=localhost
volumes:
worker_dependency_cache:

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v1.3.5
uses: dependabot/fetch-metadata@v1.3.6
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs

View File

@@ -40,4 +40,4 @@ jobs:
backend -> target
- name: cargo test
timeout-minutes: 10
run: mkdir frontend/build && cd backend && DATABASE_URL=postgres://postgres:changeme@postgres:5432/windmill cargo test --all -- --nocapture
run: mkdir frontend/build && cd backend && touch windmill-api/openapi-deref.yaml && DATABASE_URL=postgres://postgres:changeme@postgres:5432/windmill cargo test --all -- --nocapture

View File

@@ -12,4 +12,9 @@ jobs:
- uses: actions/checkout@v3
- name: Change versions
run: ./.github/change-versions.sh "$(cat version.txt)"
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: update lockfile
run: cd backend && cargo generate-lockfile
- uses: stefanzweifel/git-auto-commit-action@v4

47
.github/workflows/docker-310.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
name: Build and push windmill with python 3.10 and openbb
on: workflow_dispatch
concurrency:
group: ${{ github.ref }}-openbb
cancel-in-progress: true
permissions:
contents: read
id-token: write
packages: write
jobs:
build_ee:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v2
- uses: depot/setup-action@v1
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
uses: depot/build-push-action@v1
with:
context: .
push: true
file: ./docker/DockerfileOpenbb
build-args: |
features=enterprise
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:openbb
labels: |
org.opencontainers.image.licenses=Windmill-Enterprise-License

View File

@@ -1,62 +1,60 @@
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
LOCAL_REGISTRY: registry.wimill.xyz
# env:
# REGISTRY: ghcr.io
# IMAGE_NAME: ${{ github.repository }}
name: Build and push arm docker image
on:
push:
branches: [main]
tags: ["*"]
# name: Build and push arm docker image
# on:
# push:
# branches: [main]
# tags: ["*"]
concurrency:
group: ${{ github.ref }}-arm
cancel-in-progress: true
# concurrency:
# group: ${{ github.ref }}-arm
# cancel-in-progress: true
jobs:
publish_arm:
runs-on: [self-hosted, new]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
# permissions:
# contents: read
# id-token: write
# packages: write
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# jobs:
# publish_arm:
# runs-on: ubuntu-22.04
# steps:
# - uses: actions/checkout@v3
# with:
# fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# - uses: depot/setup-action@v1
- name: Docker meta
id: meta-slim-public
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# - name: Docker meta
# id: meta-slim-public
# uses: docker/metadata-action@v4
# with:
# images: |
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# tags: |
# type=ref,event=branch
# type=ref,event=pr
# type=semver,pattern={{version}}
# type=semver,pattern={{major}}.{{minor}}
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Login to registry
# uses: docker/login-action@v2
# with:
# registry: ${{ env.REGISTRY }}
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ steps.meta-slim-public.outputs.tags }}
labels: |
${{ steps.meta-slim-public.outputs.labels }}
org.opencontainers.image.licenses=AGPLv3
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-arm:buildcache
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-arm:buildcache,mode=max
# - name: Build and push publicly
# uses: depot/build-push-action@v1
# with:
# context: .
# push: true
# platforms: linux/amd64,linux/arm64
# tags: |
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# ${{ steps.meta-slim-public.outputs.tags }}
# labels: |
# ${{ steps.meta-slim-public.outputs.labels }}
# org.opencontainers.image.licenses=AGPLv3

View File

@@ -9,22 +9,27 @@ on:
push:
branches: [main]
tags: ["*"]
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
packages: write
jobs:
build:
runs-on: [self-hosted, new]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v2
- uses: depot/setup-action@v1
- name: Login to registry
uses: docker/login-action@v2
@@ -33,36 +38,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta-slim
if: github.event_name == 'pull_request'
uses: docker/metadata-action@v4
with:
images: |
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push publicly
uses: docker/build-push-action@v3
if: github.event_name == 'pull_request'
with:
context: .
push: true
tags: |
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest
${{ steps.meta-slim.outputs.tags }}
labels: |
${{ steps.meta-slim.outputs.labels }}
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache,mode=max
- name: Docker meta
id: meta-public
if: github.event_name != 'pull_request'
uses: docker/metadata-action@v4
with:
images: |
@@ -75,47 +52,31 @@ jobs:
- name: Build and push publicly
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v3
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest
${{ steps.meta-public.outputs.tags }}
labels: |
${{ steps.meta-public.outputs.labels }}
org.opencontainers.image.licenses=AGPLv3
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache,mode=max
build_ee:
runs-on: [self-hosted, new]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Docker meta
id: meta-slim
if: github.event_name == 'pull_request'
uses: docker/metadata-action@v4
with:
images: |
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v2
- uses: depot/setup-action@v1
- name: Docker meta
id: meta-ee-public
if: github.event_name != 'pull_request'
uses: docker/metadata-action@v4
with:
images: |
@@ -128,15 +89,13 @@ jobs:
- name: Login to registry
uses: docker/login-action@v2
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v3
uses: depot/build-push-action@v1
with:
context: .
push: true
@@ -149,8 +108,7 @@ jobs:
labels: |
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-ee:buildcache
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-ee:buildcache,mode=max
playwright:
runs-on: [self-hosted, new]
@@ -172,14 +130,14 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: "Docker"
run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest)"
run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)"
id: docker-container
- uses: actions/setup-node@v3
with:
node-version: 16
- name: "Playwright run"
timeout-minutes: 2
run: cd frontend && npm ci @playwright/test && npx playwright install && npm run test
run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=http://localhost:8000 && npm run test
- name: "Clean up"
run: docker kill ${{ steps.docker-container.outputs.id }}
if: always()
@@ -188,7 +146,6 @@ jobs:
publish_privately_heavy:
needs: [build_ee]
runs-on: [self-hosted, new]
if: github.event_name != 'pull_request'
steps:
- uses: actions/checkout@v3
with:
@@ -202,7 +159,6 @@ jobs:
with:
images: |
${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr

17
.github/workflows/frontend-check.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: check frontend build
on:
pull_request:
types: [opened,synchronize,reopened,closed]
paths:
- "frontend/**"
jobs:
npm_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: "npm check"
timeout-minutes: 2
run: cd frontend && npm ci && npm run generate-backend-client && npm run check

View File

@@ -74,6 +74,7 @@ jobs:
${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ steps.metalocal.outputs.tags }}
${{ steps.meta.outputs.tags }}
registry.uffizzi.com/windmill-lsp:60d
labels: ${{ steps.metalocal.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

93
.github/workflows/uffizzi-build.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: Build PR Image
on:
pull_request:
types: [opened,synchronize,reopened,closed]
paths:
- "backend/**"
- ".github/uffizzi/**"
- ".github/workflows/**"
workflow_dispatch:
jobs:
build-windmill:
name: Build and Push `windmill`
runs-on: ubuntu-latest
if: ${{ (github.event_name != 'pull_request' || github.event.action != 'closed')}}
outputs:
tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Generate UUID image name
id: uuid
run: echo "UUID_TAG_APP=$(uuidgen)" >> $GITHUB_ENV
- name: Docker metadata
id: meta
uses: docker/metadata-action@v3
with:
images: registry.uffizzi.com/${{ env.UUID_TAG_APP }}
tags: type=raw,value=60d
- name: Build and Push Image to registry.uffizzi.com ephemeral registry
uses: docker/build-push-action@v2
with:
push: true
context: ./
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
render-compose-file:
name: Render Docker Compose File
# Pass output of this workflow to another triggered by `workflow_run` event.
runs-on: ubuntu-latest
needs:
- build-windmill
outputs:
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Render Compose File
run: |
WINDMILL_IMAGE=${{ needs.build-windmill.outputs.tags }}
export WINDMILL_IMAGE
LSP_IMAGE=registry.uffizzi.com/windmill-lsp:60d
export LSP_IMAGE
envsubst '${WINDMILL_IMAGE} ${LSP_IMAGE}' < ./.github/uffizzi/docker-compose.uffizzi.yml > docker-compose.rendered.yml
cat docker-compose.rendered.yml
- name: Upload Rendered Compose File as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: docker-compose.rendered.yml
retention-days: 2
- name: Serialize PR Event to File
run: |
cat << EOF > event.json
${{ toJSON(github.event) }}
EOF
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
retention-days: 2
delete-preview:
name: Call for Preview Deletion
runs-on: ubuntu-latest
if: ${{ github.event.action == 'closed' }}
steps:
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
- name: Serialize PR Event to File
run: echo '${{ toJSON(github.event) }}' > event.json
- name: Upload PR Event as Artifact
uses: actions/upload-artifact@v3
with:
name: preview-spec
path: event.json
retention-days: 2

115
.github/workflows/uffizzi-preview.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Deploy Uffizzi Preview
on:
workflow_run:
workflows:
- "Build PR Image"
types:
- completed
jobs:
cache-compose-file:
name: Cache Compose File
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs:
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }}
pr-number: ${{ env.PR_NUMBER }}
steps:
- name: 'Download artifacts'
# Fetch output (zip archive) from the workflow run that triggered this workflow.
uses: actions/github-script@v6
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "preview-spec"
})[0];
if (matchArtifact === undefined) {
throw TypeError('Build Artifact not found!');
}
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
- name: 'Unzip artifact'
run: unzip preview-spec.zip
- name: Read Event into ENV
run: |
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
cat event.json >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: Predict Deployment URL
id: url
# Replace dots in the repo name with the plus sign
run: |
REPO=$(echo ${{ github.repository }} | sed 's/\./+/g')
echo "EXPECTED_URL=https://app.uffizzi.com/github.com/$REPO/pull/$PR_NUMBER" >> $GITHUB_ENV
- name: Re-Render Compose File
run: |
OAUTH_JSON_BASE64=${{ secrets.OAUTH_JSON_BASE64 }}
export OAUTH_JSON_BASE64
envsubst '${OAUTH_JSON_BASE64} ${EXPECTED_URL}' < docker-compose.rendered.yml > docker-compose.uffizzi.yml
# cat docker-compose.uffizzi.yml
- name: Hash Rendered Compose File
id: hash
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.uffizzi.yml | awk '{ print $1 }')" >> $GITHUB_ENV
- name: Cache Rendered Compose File
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
uses: actions/cache@v3
with:
path: docker-compose.uffizzi.yml
key: ${{ env.COMPOSE_FILE_HASH }}
- name: DEBUG - Print Job Outputs
if: ${{ runner.debug }}
run: |
echo "PR number: ${{ env.PR_NUMBER }}"
echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}"
cat event.json
deploy-uffizzi-preview:
name: Use Remote Workflow to Preview on Uffizzi
needs:
- cache-compose-file
if: ${{ github.event.workflow_run.conclusion == 'success' }}
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
with:
# If this workflow was triggered by a PR close event, cache-key will be an empty string
# and this reusable workflow will delete the preview deployment.
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
compose-file-cache-path: docker-compose.uffizzi.yml
server: https://app.uffizzi.com
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
permissions:
contents: read
pull-requests: write
id-token: write
playwright:
runs-on: ubuntu-latest
needs:
- deploy-uffizzi-preview
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- name: "Playwright run"
timeout-minutes: 2
run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=${{ needs.deploy-uffizzi-preview.outputs.url }} && npm run test

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.analysis.typeCheckingMode": "basic"
}

View File

@@ -1,6 +1,142 @@
# Changelog
## [1.62.0](https://github.com/windmill-labs/windmill/compare/v1.61.1...v1.62.0) (2023-02-03)
### Features
* add INCLUDE_HEADERS env variable to pass value from request headers ([0921ba0](https://github.com/windmill-labs/windmill/commit/0921ba008535e945f2ec3255728c2e8c1f4c36dc))
* add WHITELIST_WORKSPACES and BLACKLIST_WORKSPACES ([99568ea](https://github.com/windmill-labs/windmill/commit/99568eaa473d57123a7dde4007f8812e0053fb3f))
* Add workspace webhook ([#1158](https://github.com/windmill-labs/windmill/issues/1158)) ([b9ac60f](https://github.com/windmill-labs/windmill/commit/b9ac60f8bb0662e364606c4b7b8a6e3c1e7e4041))
* adding worker_busy ([23007f7](https://github.com/windmill-labs/windmill/commit/23007f7a71630fc2040e1be39db83ba56689e3c4))
* **cli:** 2-Way sync ([#1071](https://github.com/windmill-labs/windmill/issues/1071)) ([cdd1619](https://github.com/windmill-labs/windmill/commit/cdd16195aeaf32e1f1d0648f48e4843954d16d9c))
* **frontend:** App initial loading animations ([#1176](https://github.com/windmill-labs/windmill/issues/1176)) ([3305481](https://github.com/windmill-labs/windmill/commit/3305481d5d4ce598ceb57256cea851869cdaf25e))
* **python:** add ADDITIONAL_PYTHON_PATHS ([14b32be](https://github.com/windmill-labs/windmill/commit/14b32be8b229372c57a167fd74cb958a96f0e8e6))
### Bug Fixes
* **frontend:** Render popups above components in app editor ([#1171](https://github.com/windmill-labs/windmill/issues/1171)) ([bc8d1a3](https://github.com/windmill-labs/windmill/commit/bc8d1a375ec7886357ce0ef5971bb35013c94d61))
* **frontend:** Various fixes and improvements ([#1177](https://github.com/windmill-labs/windmill/issues/1177)) ([9f5500c](https://github.com/windmill-labs/windmill/commit/9f5500c1965ea50796d3bf289c0f9e0c929427f4))
* navigate to new script page before saving script ([f171cd8](https://github.com/windmill-labs/windmill/commit/f171cd8b7c46677173572bac256cbb489a1b8526))
## [1.61.1](https://github.com/windmill-labs/windmill/compare/v1.61.0...v1.61.1) (2023-01-31)
### Bug Fixes
* **backend:** compile issue ([df8cc1f](https://github.com/windmill-labs/windmill/commit/df8cc1f2482b3d8b1530cdaef1361303ff5cadff))
## [1.61.0](https://github.com/windmill-labs/windmill/compare/v1.60.0...v1.61.0) (2023-01-31)
### Features
* add openapi viewer ([#1094](https://github.com/windmill-labs/windmill/issues/1094)) ([1337811](https://github.com/windmill-labs/windmill/commit/1337811438d48e23133f68e9157bd185d5fe4a82))
* add PIP_LOCAL_DEPENDENCIES ([b7db4c7](https://github.com/windmill-labs/windmill/commit/b7db4c78c4629f1fd2dfd7a338f783b16f07b24d))
* add QUEUE_LIMIT_WAIT_RESULT ([51a8810](https://github.com/windmill-labs/windmill/commit/51a8810aa0a9ab7702df459dd270278d42bd3899))
* add resource and resource type from json ([080ecb0](https://github.com/windmill-labs/windmill/commit/080ecb04d7a08d035fe07f179975b52bc0f77297))
* add sql as a valid type in Python ([0172587](https://github.com/windmill-labs/windmill/commit/0172587b129ce54d96dc99336a1f56c66ebdbef5))
* add sync webhook for flows ([f377c84](https://github.com/windmill-labs/windmill/commit/f377c84f5a2148a2bbb7c16e93f13e1d85ceb17e))
* **backend:** add queue_limit + configurable timeout + fix timeout cancel ([eef3bab](https://github.com/windmill-labs/windmill/commit/eef3bab6e4d9f1af1435db868c707a692558ab74))
* **deno:** add support for DENO_AUTH_TOKENS ([832ddab](https://github.com/windmill-labs/windmill/commit/832ddabdf2239521368e5f96df144abce0db31c2))
* **deno:** allow overriding deno sandboxing with DENO_FLAGS' ([7f40373](https://github.com/windmill-labs/windmill/commit/7f40373fd64005d87972854a565c6cf521232982))
* **frontend:** Add app inputs configurations ([#1142](https://github.com/windmill-labs/windmill/issues/1142)) ([3ed16b8](https://github.com/windmill-labs/windmill/commit/3ed16b88a42e4db6e12f8557c5bbaa2d832b1c17))
* **frontend:** Add app preview lock ([#1127](https://github.com/windmill-labs/windmill/issues/1127)) ([6a88e8c](https://github.com/windmill-labs/windmill/commit/6a88e8c4f4d6fa5c393ce27b2040784a74a73b06))
* **frontend:** Add copy button option to app text display component ([#1090](https://github.com/windmill-labs/windmill/issues/1090)) ([bdfc38d](https://github.com/windmill-labs/windmill/commit/bdfc38d954a3c5548fb7f9ee6f80f741eff8cb67))
* **frontend:** Add default codes to app editor ([#1099](https://github.com/windmill-labs/windmill/issues/1099)) ([c50c740](https://github.com/windmill-labs/windmill/commit/c50c7406f267b480af2a01b47e3fcfa1d763db7a))
* **frontend:** Add HTML result rendering ([#1160](https://github.com/windmill-labs/windmill/issues/1160)) ([c01bf70](https://github.com/windmill-labs/windmill/commit/c01bf70f62680a4b77812ac6eb64aca2b15d9a8d))
* **frontend:** Add more integration icons ([#1097](https://github.com/windmill-labs/windmill/issues/1097)) ([2191e85](https://github.com/windmill-labs/windmill/commit/2191e852318f069489f77a4f1c44aadf248c7f53))
* **frontend:** add plotly support ([a4f8f9e](https://github.com/windmill-labs/windmill/commit/a4f8f9e1cf80395d5cd1229c8dd5dda244e2ba7f))
* **frontend:** add selectedRowIndex to the table outputs ([#1145](https://github.com/windmill-labs/windmill/issues/1145)) ([f05f9e4](https://github.com/windmill-labs/windmill/commit/f05f9e4edb928e7a8e3e66a62de9c6487684a14b))
* **frontend:** Add Supabase resource ([#1107](https://github.com/windmill-labs/windmill/issues/1107)) ([12b00a8](https://github.com/windmill-labs/windmill/commit/12b00a808d1f12827a7bc26518cc6f972bdde917))
* **frontend:** add support for background scripts + add FormButtonCo… ([#1124](https://github.com/windmill-labs/windmill/issues/1124)) ([e969af9](https://github.com/windmill-labs/windmill/commit/e969af9e44d1b4409064080e8662552ee3e262e8))
* **frontend:** Add surreal db logo ([#1102](https://github.com/windmill-labs/windmill/issues/1102)) ([d811675](https://github.com/windmill-labs/windmill/commit/d81167588227f2cc433aab64551d96d21a589c5b))
* **frontend:** Add tooltip to app recompute ([#1122](https://github.com/windmill-labs/windmill/issues/1122)) ([4dfdf37](https://github.com/windmill-labs/windmill/commit/4dfdf374af358ef46ee8057373546719c6570067))
* **frontend:** add vega-lite component ([bd79938](https://github.com/windmill-labs/windmill/commit/bd79938bed6da3875a4a2dd72dad14dedbf25ddf))
* **frontend:** Display error as an icon in order to avoid clutter wh… ([#1143](https://github.com/windmill-labs/windmill/issues/1143)) ([22b8fed](https://github.com/windmill-labs/windmill/commit/22b8fed9d904a37aae66f6d957f4987f6ca9955c))
* **frontend:** Open debug runs from component ([#1155](https://github.com/windmill-labs/windmill/issues/1155)) ([73bc13b](https://github.com/windmill-labs/windmill/commit/73bc13bb7d4b1eb25a3a726ac9e6bb80120a495f))
* **frontend:** Update app table component styles ([#1100](https://github.com/windmill-labs/windmill/issues/1100)) ([172b5db](https://github.com/windmill-labs/windmill/commit/172b5dba8f4c3aaf11569c72313ad74845c668a6))
* **python:** add support for extra args in python ([772c768](https://github.com/windmill-labs/windmill/commit/772c768cda094f208a5efb7aab03eee3a8f38f68))
### Bug Fixes
* **frontend:** Add default value for text, number and date input + fix issues with number input + add date input in the settings panel ([#1135](https://github.com/windmill-labs/windmill/issues/1135)) ([8f90602](https://github.com/windmill-labs/windmill/commit/8f906026b3203702c3b6a30bcac9fb2aca985c29))
* **frontend:** Add highlight to selected workspace ([#1159](https://github.com/windmill-labs/windmill/issues/1159)) ([f221a6c](https://github.com/windmill-labs/windmill/commit/f221a6c17f145d0c42f7faf785c37f4037308973))
* **frontend:** add missing condition to properly select first row ([#1128](https://github.com/windmill-labs/windmill/issues/1128)) ([3d873ed](https://github.com/windmill-labs/windmill/commit/3d873ed51c769005981a8d8dfb95faa3ca33bb83))
* **frontend:** App form component display ([#1096](https://github.com/windmill-labs/windmill/issues/1096)) ([339742c](https://github.com/windmill-labs/windmill/commit/339742ca77dd0fda19d5a262617e42c341ef5871))
* **frontend:** App script list panel overflow ([#1101](https://github.com/windmill-labs/windmill/issues/1101)) ([7bc59d9](https://github.com/windmill-labs/windmill/commit/7bc59d9d2650b623a2b481a727ffc495b4216f22))
* **frontend:** App table action button cell ([#1149](https://github.com/windmill-labs/windmill/issues/1149)) ([e989662](https://github.com/windmill-labs/windmill/commit/e98966283dd9b57cc07da34876a90d19210c2927))
* **frontend:** App table header z-index ([#1120](https://github.com/windmill-labs/windmill/issues/1120)) ([59c4cc2](https://github.com/windmill-labs/windmill/commit/59c4cc2058f86deea793b61de59e2936e50e5577))
* **frontend:** Check if hiddenInlineScripts are undefined before iterating over them ([#1134](https://github.com/windmill-labs/windmill/issues/1134)) ([71a443e](https://github.com/windmill-labs/windmill/commit/71a443e3c56d2b8c951de6e3701a411ad1a0ce34))
* **frontend:** fix first row selection ([#1125](https://github.com/windmill-labs/windmill/issues/1125)) ([6c9daf7](https://github.com/windmill-labs/windmill/commit/6c9daf70021859dcd7cef717bc3acdfa88cffd02))
* **frontend:** Fix id generation when a second action ([#1110](https://github.com/windmill-labs/windmill/issues/1110)) ([4f86981](https://github.com/windmill-labs/windmill/commit/4f869811fee73826b2b10965241d2d8dba59dc2a))
* **frontend:** Make sure AppSelect items are an array ([#1144](https://github.com/windmill-labs/windmill/issues/1144)) ([24b1fa0](https://github.com/windmill-labs/windmill/commit/24b1fa0ae327c984841f9ed8b163b3fccc6da258))
* **frontend:** Make sure that old apps are rendering properly ([#1132](https://github.com/windmill-labs/windmill/issues/1132)) ([a78486d](https://github.com/windmill-labs/windmill/commit/a78486d7e08f76e22406063288b35e9030974d7a))
* **frontend:** Playwright ([#1108](https://github.com/windmill-labs/windmill/issues/1108)) ([f0435f5](https://github.com/windmill-labs/windmill/commit/f0435f5f81941c5b49500003aa27956d627daadb))
* **frontend:** Prepare app scripts code for export ([#1123](https://github.com/windmill-labs/windmill/issues/1123)) ([173093a](https://github.com/windmill-labs/windmill/commit/173093a40321f6ad35bf766a5554b21cea388771))
* **frontend:** Prevent modal from hijacking all keypress event ([#1136](https://github.com/windmill-labs/windmill/issues/1136)) ([aa6de3b](https://github.com/windmill-labs/windmill/commit/aa6de3bb5746b9d99c8e3a52e6a9fff10d97bc6a))
* **frontend:** Revert component input panel change ([#1092](https://github.com/windmill-labs/windmill/issues/1092)) ([0419e7e](https://github.com/windmill-labs/windmill/commit/0419e7e1c9239fd3cbc49acf82a73e9c01938153))
* **frontend:** Runnable table overflow ([#1119](https://github.com/windmill-labs/windmill/issues/1119)) ([462adbe](https://github.com/windmill-labs/windmill/commit/462adbe42f823646413a5003fd71f3dd473c0728))
* **frontend:** Select the first row by default, and remove the abilit… ([#1121](https://github.com/windmill-labs/windmill/issues/1121)) ([3c483f5](https://github.com/windmill-labs/windmill/commit/3c483f533759b9b4e589055dbddb31f294bea8fa))
* **frontend:** Show app builder header always on top ([#1118](https://github.com/windmill-labs/windmill/issues/1118)) ([631a3da](https://github.com/windmill-labs/windmill/commit/631a3da17f05a3d29defdf96a50d7e96a9f8baad))
* **frontend:** Update app scripts pane ([#1146](https://github.com/windmill-labs/windmill/issues/1146)) ([18f30c8](https://github.com/windmill-labs/windmill/commit/18f30c8286f8240158643ade8b0ef4607a80fbb0))
* **frontend:** Use absolute path on connect images ([#1095](https://github.com/windmill-labs/windmill/issues/1095)) ([43e069e](https://github.com/windmill-labs/windmill/commit/43e069eb96c0af7d3a1fe1db4f4b69f8e31e7438))
* improvements for error handling as first step of flow ([b77c239](https://github.com/windmill-labs/windmill/commit/b77c239f307a37777acb083b0cdb5c0d214a9dd8))
## [1.60.0](https://github.com/windmill-labs/windmill/compare/v1.59.0...v1.60.0) (2023-01-11)
### Features
* add 'add user to workspace' ([a14623f](https://github.com/windmill-labs/windmill/commit/a14623feaab4a36c01d558b775a42e587a74cdc9))
* **frontend:** Add frost to color palette ([#1084](https://github.com/windmill-labs/windmill/issues/1084)) ([8e72007](https://github.com/windmill-labs/windmill/commit/8e7200736827e8f6e593f900124b1bd1bc0bd5f2))
### Bug Fixes
* **frontend:** Keep pane resizer under open drawer ([#1089](https://github.com/windmill-labs/windmill/issues/1089)) ([cb25f88](https://github.com/windmill-labs/windmill/commit/cb25f883005b99b4ce98e8ae7b8253a8a2fedb5b))
## [1.59.0](https://github.com/windmill-labs/windmill/compare/v1.58.0...v1.59.0) (2023-01-09)
### Features
* add relative imports for python scripts ([#1075](https://github.com/windmill-labs/windmill/issues/1075)) ([5347cd4](https://github.com/windmill-labs/windmill/commit/5347cd46a996b4cf48a96fbb873e4d029ca4f75f))
### Bug Fixes
* **frontend:** Iconed resource height issue ([#1073](https://github.com/windmill-labs/windmill/issues/1073)) ([a84eb9b](https://github.com/windmill-labs/windmill/commit/a84eb9b1f7e1b10c960ee1594ef476e7ba146f5e))
## [1.58.0](https://github.com/windmill-labs/windmill/compare/v1.57.1...v1.58.0) (2023-01-07)
### Features
* add archive/unarchive/delete workspace ([6edf9b9](https://github.com/windmill-labs/windmill/commit/6edf9b9946d613b599cb91688c4986044caaba8d))
* add hub support for apps ([50453ca](https://github.com/windmill-labs/windmill/commit/50453ca690dfd936474ebbf000e36ae1006b188b))
* add min/max constraint to number + slider component ([0bcdcae](https://github.com/windmill-labs/windmill/commit/0bcdcaedcfdf7b7f76f703df3bf50d97dd389995))
* add support for yaml format as a string format ([5204e4a](https://github.com/windmill-labs/windmill/commit/5204e4a75d74e6bb4087dee7087390f7c0388e51))
* **frontend:** Add integration icons ([#1063](https://github.com/windmill-labs/windmill/issues/1063)) ([45acb89](https://github.com/windmill-labs/windmill/commit/45acb89f87ad78c48a1ba6abf1bd1424088b41c4))
* **frontend:** Toggle to hide optional inputs ([#1060](https://github.com/windmill-labs/windmill/issues/1060)) ([4d6a568](https://github.com/windmill-labs/windmill/commit/4d6a568820ceb6c064dc2871085b80412e18c379))
* **frontend:** Update app auto-refresh button ([#1062](https://github.com/windmill-labs/windmill/issues/1062)) ([34e3331](https://github.com/windmill-labs/windmill/commit/34e33319192f6d747d84fc6559853410f5d72ec8))
### Bug Fixes
* **frontend:** Remove popover hover styles ([#1064](https://github.com/windmill-labs/windmill/issues/1064)) ([76a860f](https://github.com/windmill-labs/windmill/commit/76a860fe538dadfc6691074384f92db1a331063d))
## [1.57.1](https://github.com/windmill-labs/windmill/compare/v1.57.0...v1.57.1) (2023-01-02)
### Bug Fixes
* preserver order changes for flows' schema ([2c8e98a](https://github.com/windmill-labs/windmill/commit/2c8e98a9c7fe3fdd48c851c0575fdb1d87c953a9))
* support setting undefined states ([ab0aeb0](https://github.com/windmill-labs/windmill/commit/ab0aeb0df825fb5afefbefae6739179dbbbc5f30))
## [1.57.0](https://github.com/windmill-labs/windmill/compare/v1.56.1...v1.57.0) (2023-01-01)

View File

@@ -1,5 +1,15 @@
{$BASE_URL} {
{
auto_https off
}
http://{$BASE_URL} {
bind {$ADDRESS}
reverse_proxy /ws/* http://lsp:3001
reverse_proxy /* http://windmill_server:8000
reverse_proxy /* http://windmill:8000
}
https://{$BASE_URL} {
bind {$ADDRESS}
reverse_proxy /ws/* http://localhost:3001
}
}

View File

@@ -51,12 +51,16 @@ COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
RUN cd /backend/windmill-api && . ./build_openapi.sh
RUN npm run generate-backend-client
ENV NODE_OPTIONS "--max-old-space-size=8192"
RUN npm run build
RUN npm run check
FROM rust_base AS planner
COPY ./openflow.openapi.yaml /openflow.openapi.yaml
@@ -75,6 +79,7 @@ COPY ./openflow.openapi.yaml /openflow.openapi.yaml
COPY ./backend ./
COPY --from=frontend /frontend /frontend
COPY --from=frontend /backend/windmill-api/openapi-deref.yaml ./windmill-api/openapi-deref.yaml
COPY .git/ .git/
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features"

View File

@@ -91,7 +91,7 @@ Windmill is <b>fully open-sourced (AGPLv3)</b>:
shared on [WindmillHub](https://hub.windmill.dev).
![Step 4](./imgs/windmill-flow.png)
4. (Coming soon) Build complex UI on top of your scripts and flows.
4. Build complex UI on top of your scripts and flows.
![Step 5](./imgs/windmill-builder.png)
Scripts and flows can also be triggered by a cron schedule '*/5 * * * *' or
@@ -182,7 +182,7 @@ compiling from source or using without a postgres super user, see
`docker compose up` with the following docker-compose is sufficient:
<https://github.com/windmill-labs/windmill/blob/main/docker-compose.yml>
Go to https://localhost et voilà :)
Go to http://localhost et voilà :)
For older kernels < 4.18, set `DISABLE_NUSER=true` as env variable, otherwise
nsjail will not be able to launch the isolated scripts.
@@ -278,13 +278,15 @@ upcoming CLI tool.
| ------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| DATABASE_URL | | The Postgres database url. | All |
| DISABLE_NSJAIL | true | Disable Nsjail Sandboxing | |
| NUM_WORKERS | 3 | The number of worker per Worker instance (set to 1 on Eks to have 1 pod = 1 worker) | Worker |
| NUM_WORKERS | 3 | The number of worker per Worker instance (set to 1 on Eks to have 1 pod = 1 worker, set to 0 for an API only instance) | Worker |
| DISABLE_SERVER | false | Binary would operate as a worker only instance | Worker |
| METRICS_ADDR | None | The socket addr at which to expose Prometheus metrics at the /metrics path. Set to "true" to expose it on port 8001 | All |
| JSON_FMT | false | Output the logs in json format instead of logfmt | All |
| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance | Server |
| BASE_INTERNAL_URL | http://localhost:8000 | The base url that is reachable by your workers to talk to the Servers. This help avoiding going through the external load balancer for VPC-internal requests. | Worker |
| TIMEOUT | 300 | The timeout in seconds for the execution of a script | Worker |
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
| MAX_LOG_SIZE | 500000 | The maximum number of characters a job can emit (log + result) | Worker |
| DISABLE_NUSER | false | If Nsjail is enabled, disable the nsjail's `clone_newuser` setting | Worker |
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
@@ -303,6 +305,17 @@ upcoming CLI tool.
| HOME | None | The home directory to use for Go and Bash , usually inherited | Worker |
| DATABASE_CONNECTIONS | 50 (Server)/3 (Worker) | The max number of connections in the database connection pool | All |
| SUPERADMIN_SECRET | None | A token that would let the caller act as a virtual superadmin superadmin@windmill.dev | Server |
| TIMEOUT_WAIT_RESULT | 20 | The number of seconds to wait before timeout on the 'run_wait_result' endpoint | Worker |
| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker |
| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker |
| DENO_FLAGS | None | Override the flags passed to deno (default --allow-all) to tighten permissions. Minimum permissions needed are "--allow-read=args.json --allow-write=result.json" | Worker |
| PIP_LOCAL_DEPENDENCIES | None | Specify dependencies that are installed locally and do not need to be solved nor installed again |
| ADDITIONAL_PYTHON_PATHS | None | Specify python paths (separated by a :) to be appended to the PYTHONPATH of the python jobs. To be used with PIP_LOCAL_DEPENDENCIES to use python codebases within Windmill | Worker |
| INCLUDE_HEADERS | None | Whitelist of headers that are passed to jobs as args (separated by a comma) | Server |
| WHITELIST_WORKSPACES | None | Whitelist of workspaces this worker takes job from | Worker |
| BLACKLIST_WORKSPACES | None | Blacklist of workspaces this worker takes job from | Worker |
## Run a local dev setup

1
backend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
target/
.env
oauth.json
windmill-api/openapi-deref.yaml

3
backend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.analysis.typeCheckingMode": "basic"
}

1432
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.57.0"
version = "1.62.0"
authors.workspace = true
edition.workspace = true
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "1.57.0"
version = "1.62.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -45,6 +45,9 @@ tokio-metrics.workspace = true
rand.workspace = true
chrono.workspace = true
git-version.workspace = true
rsa.workspace = true
base64.workspace = true
sha2.workspace = true
[dev-dependencies]
serde_json.workspace = true
@@ -80,7 +83,7 @@ chrono = { version = "^0", features = ["serde"] }
tracing = "^0"
tracing-subscriber = { version = "^0", features = ["env-filter", "json"] }
prometheus = { version = "^0", default-features = false }
cookie = { version = "^0" }
cookie = { version = "0.16.2" }
phf = { version = "0.11", features = ["macros"] }
rust-embed = "^6"
mime_guess = "^2"
@@ -140,3 +143,5 @@ lazy_static = "1.4.0"
serde_derive = "1.0.147"
const_format = { version = "0.2", features = ["rust_1_64", "rust_1_51"] }
dyn-iter = "0.2.0"
rsa = "0.7.2"
async-stripe = { version = "0.14", features = ["runtime-tokio-hyper", "checkout"] }

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,16 @@
-- Add up migration script here
-- Add up migration script here
UPDATE script SET content = 'import wmill from "https://deno.land/x/wmill@v1.57.0/main.ts";
export async function main() {
await run(
"workspace", "add", "__automation", "admins", Deno.env.get("BASE_INTERNAL_URL") + "/", "--token", Deno.env.get("WM_TOKEN"));
await run("hub", "pull");
}
async function run(...cmd: string[]) {
console.log("Running \"" + cmd.join('' '') + "\"");
await wmill.parse(cmd);
}', summary = 'Synchronize Hub Resource types with admins workspace',
description = 'Basic administrative script to sync latest resource types from hub to share to every workspace. Recommended to run at least once. On a schedule by default.'
WHERE hash = -28028598712388162 AND workspace_id = 'admins';

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE workspace_settings ADD COLUMN customer_id VARCHAR(100);
ALTER TABLE workspace_settings ADD COLUMN plan VARCHAR(40);

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,5 @@
-- Add up migration script here
ALTER TABLE
workspace_settings
ADD
COLUMN webhook text;

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE account ALTER COLUMN refresh_token TYPE VARCHAR(1500);

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,4 @@
-- Add up migration script here
GRANT ALL PRIVILEGES ON TABLE favorite TO windmill_admin;
GRANT ALL PRIVILEGES ON TABLE favorite TO windmill_user;

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,25 @@
-- Add up migration script here
CREATE POLICY see_folder_extra_perms_user ON capture FOR ALL
USING (SPLIT_PART(capture.path, '/', 1) = 'f' AND SPLIT_PART(capture.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]))
WITH CHECK (SPLIT_PART(capture.path, '/', 1) = 'f' AND SPLIT_PART(capture.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
DO
$do$
DECLARE
i text;
arr text[] := array['queue', 'completed_job'];
BEGIN
FOREACH i IN ARRAY arr
LOOP
EXECUTE FORMAT(
$$
CREATE POLICY see_folder_extra_perms_user ON %1$I FOR ALL
USING (%1$I.visible_to_owner IS true AND SPLIT_PART(%1$I.script_path, '/', 1) = 'f' AND SPLIT_PART(%1$I.script_path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]));
$$,
i
);
END LOOP;
END
$do$;

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,5 @@
-- Add up migration script here
CREATE POLICY see_folder_extra_perms_user ON account FOR ALL
USING (SPLIT_PART(account.owner, '/', 1) = 'f' AND SPLIT_PART(account.owner, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]))
WITH CHECK (SPLIT_PART(account.owner, '/', 1) = 'f' AND SPLIT_PART(account.owner, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE queue ADD COLUMN mem_peak INTEGER;
ALTER TABLE completed_job ADD COLUMN mem_peak INTEGER;

View File

@@ -80,7 +80,7 @@ pub fn parse_python_signature(code: &str) -> error::Result<MainArgSignature> {
let def_arg_start = params.args.len() - params.defaults.len();
Ok(MainArgSignature {
star_args: params.vararg.is_some(),
star_kwargs: params.vararg.is_some(),
star_kwargs: params.kwarg.is_some(),
args: params
.args
.into_iter()
@@ -104,6 +104,7 @@ pub fn parse_python_signature(code: &str) -> error::Result<MainArgSignature> {
"bytes" => Typ::Bytes,
"datetime" => Typ::Datetime,
"datetime.datetime" => Typ::Datetime,
"Sql" | "sql" => Typ::Sql,
_ => Typ::Resource(id),
},
_ => Typ::Unknown,
@@ -135,7 +136,10 @@ fn to_value(et: &ExprKind) -> Option<serde_json::Value> {
.into_iter()
.zip(values)
.map(|(k, v)| {
let key = to_value(&k.node)
let key = k
.as_ref()
.map(|x| x.node.clone())
.and_then(|n| to_value(&n))
.and_then(|x| match x {
serde_json::Value::String(s) => Some(s),
_ => None,
@@ -174,8 +178,14 @@ fn constant_to_value(c: &Constant) -> serde_json::Value {
static PYTHON_IMPORTS_REPLACEMENT: phf::Map<&'static str, &'static str> = phf_map! {
"psycopg2" => "psycopg2-binary",
"psycopg" => "psycopg[binary, pool]",
"yaml" => "pyyaml",
"git" => "GitPython"
"git" => "GitPython",
"u" => "requests",
"f" => "requests",
"shopify" => "ShopifyAPI",
"seleniumwire" => "selenium-wire",
"openbb-terminal" => "openbb[all]",
};
fn replace_import(x: String) -> String {
@@ -187,13 +197,13 @@ fn replace_import(x: String) -> String {
}
lazy_static! {
static ref RE: Regex = Regex::new(r"^\#(\S+)$").unwrap();
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)$").unwrap();
}
pub fn parse_python_imports(code: &str) -> error::Result<Vec<String>> {
let find_requirements = code
.lines()
.find_position(|x| x.starts_with("#requirements:"));
.find_position(|x| x.starts_with("#requirements:") || x.starts_with("# requirements:"));
if let Some((pos, _)) = find_requirements {
let lines = code
.lines()

File diff suppressed because it is too large Load Diff

24
backend/src/ee.rs Normal file
View File

@@ -0,0 +1,24 @@
#[cfg(feature = "enterprise")]
use rsa::{pkcs8::DecodePublicKey, signature::Verifier};
#[cfg(feature = "enterprise")]
use sha2::Sha256;
#[cfg(feature = "enterprise")]
pub fn verify_license_key(license_key: Option<String>) -> anyhow::Result<()> {
if let Some(license_key) = license_key {
let splitted_lk = license_key
.split_once(".")
.expect("license_key can be splitted with a .");
let pub_key = rsa::RsaPublicKey::from_public_key_der(
&base64::decode("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgVShzcLSPiOi+8ET8fggob1kmi47/cE12JaidPkwfGnScZItghkqtiLsct0U4kJhlp5gO89DYTBmIKadvxwY7kMsLlZzmi2emVH7c27cByGASY8QmWDNdG4Ggy/NDflGGBdAtN6gHawZAg4zHv3qpbPQGHH1/6sXIohcXhOnouwIDAQAB")?)?;
let msg = base64::decode(splitted_lk.0)?;
let signature = base64::decode(splitted_lk.1)?;
rsa::pss::VerifyingKey::<Sha256>::new(pub_key)
.verify(&msg, &rsa::pss::Signature::from(signature))
.map_err(|_| anyhow::anyhow!("Invalid license key".to_string()))?;
} else {
panic!("License key is required for the enterprise edition");
}
Ok(())
}

View File

@@ -15,6 +15,8 @@ use windmill_worker::WorkerConfig;
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
mod ee;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
@@ -190,19 +192,12 @@ pub async fn run_workers(
license_key: Option<String>,
) -> anyhow::Result<()> {
#[cfg(feature = "enterprise")]
if let Some(license_key) = license_key {
if license_key != "REQUIRED_DEC1" {
panic!("Invalid license key");
}
} else {
panic!("License key is required for the enterprise edition");
}
ee::verify_license_key(license_key)?;
#[cfg(not(feature = "enterprise"))]
if license_key.is_some() {
panic!("License key is required ONLY for the enterprise edition");
}
#[cfg(not(feature = "enterprise"))]
if !worker_config.disable_nsjail {
tracing::warn!(

View File

@@ -186,7 +186,7 @@ mod suspend_resume {
const secret = await r.text();\
console.log('Secret: ' + secret + ' ' + job + ' ' + token);\
const r2 = await fetch(
`http://localhost:${port}/api/w/test-workspace/jobs/${op}/${job}/0/${secret}?approver=ruben`,\
`http://localhost:${port}/api/w/test-workspace/jobs_u/${op}/${job}/0/${secret}?approver=ruben`,\
{\
method: 'POST',\
body: JSON.stringify('from job'),\
@@ -276,7 +276,7 @@ mod suspend_resume {
/* ImZyb20gdGVzdCIK = base64 "from test" */
reqwest::get(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/resume/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK&approver=ruben"
"http://localhost:{port}/api/w/test-workspace/jobs_u/resume/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK&approver=ruben"
))
.await
.unwrap()
@@ -333,7 +333,7 @@ mod suspend_resume {
server.close().await.unwrap();
assert_eq!(
json!({"error": "Job canceled: approval request disapproved by ruben" }),
json!({"error": {"name": "Canceled", "reason": "approval request disapproved", "message": "Job canceled: approval request disapproved by ruben", "canceler": "ruben"}}),
result
);
}
@@ -379,7 +379,7 @@ mod suspend_resume {
/* ImZyb20gdGVzdCIK = base64 "from test" */
reqwest::get(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/cancel/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
"http://localhost:{port}/api/w/test-workspace/jobs_u/cancel/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
))
.await
.unwrap()
@@ -395,7 +395,7 @@ mod suspend_resume {
let result = completed_job(flow, &db).await.result.unwrap();
assert_eq!(
json!({"error": "Job canceled: approval request disapproved by unknown" }),
json!( {"error": {"name": "Canceled", "reason": "approval request disapproved", "message": "Job canceled: approval request disapproved by unknown", "canceler": "unknown"}}),
result
);
}
@@ -462,7 +462,7 @@ export async function main(index, port) {
const buf = new Uint8Array([0]);
const sock = await Deno.connect({ port });
await sock.write(new Uint8Array([index]));
if (await sock.read(buf) != 1) throw "read";
if (await sock.read(buf) != 1) throw Error("read");
return buf[0];
}
"#
@@ -581,10 +581,17 @@ def main(last, port):
.unwrap();
assert_eq!(server.close().await, attempts);
assert!(result[1]["error"]
.as_str()
.unwrap()
.contains(r#"Uncaught (in promise) "read""#));
assert!(
result[1]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
== "read"
);
}
#[sqlx::test(fixtures("base"))]
@@ -615,6 +622,10 @@ def main(last, port):
let result = job.result.unwrap();
assert_eq!(server.close().await, attempts);
assert!(result["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("index out of range"));
@@ -684,9 +695,7 @@ def main(error, port):
result,
json!({
"recv": 42,
"from failure module": {
"error": "Error during execution of the script:\n\nTraceback (most recent call last):\n File \"/tmp/main.py\", line 14, in <module>\n res = inner_script.main(**kwargs)\n File \"/tmp/inner.py\", line 5, in main\n return sock.recv(1)[0]\nIndexError: index out of range",
}
"from failure module": {"error": {"name": "IndexError", "stack": " File \"/tmp/inner.py\", line 5, in main\n return sock.recv(1)[0]\n", "message": "index out of range"}},
})
);
}
@@ -739,9 +748,13 @@ async fn test_iteration(db: Pool<Postgres>) {
.unwrap();
assert!(matches!(result, serde_json::Value::Array(_)));
assert!(result[2]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("StopIteration: 2"));
.contains("2"));
}
#[sqlx::test(fixtures("base"))]
@@ -792,9 +805,13 @@ async fn test_iteration_parallel(db: Pool<Postgres>) {
let result = job.result.unwrap();
assert!(matches!(result, serde_json::Value::Array(_)));
assert!(result[2]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("StopIteration: 2"));
.contains("2"));
}
struct RunJob {
@@ -1767,7 +1784,7 @@ async fn test_invalid_first_step(db: Pool<Postgres>) {
assert_eq!(
job.result.unwrap(),
serde_json::json!({"error":"Expected an array value, found: {}"})
serde_json::json!( {"error": {"name": "InternalErr", "message": "Expected an array value, found: {}"}})
);
}
@@ -2083,7 +2100,7 @@ async fn test_branchall_skip_failure(db: Pool<Postgres>) {
assert_eq!(
result,
serde_json::json!([{"error": "Error during execution of the script:\n\nerror: Uncaught (in promise) Error: failure\nexport function main(){ throw Error('failure') }\n ^\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1"}, [1,3]])
serde_json::json!([{"error": {"name": "Error", "stack": "Error: failure\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1", "message": "failure"}}, [1,3]])
);
let flow: FlowValue = serde_json::from_value(json!({
@@ -2117,7 +2134,7 @@ async fn test_branchall_skip_failure(db: Pool<Postgres>) {
assert_eq!(
result,
serde_json::json!([{"error": "Error during execution of the script:\n\nerror: Uncaught (in promise) Error: failure\nexport function main(){ throw Error('failure') }\n ^\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1"}, [1, 2]])
serde_json::json!([ {"error": {"name": "Error", "stack": "Error: failure\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1", "message": "failure"}}, [1, 2]])
);
}
@@ -2256,7 +2273,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
},
"type": "rawscript",
"language": "deno",
"content": "export function main(n, l) { if (n == 0) throw l; return { l: [...l, 0] } }",
"content": "export function main(n, l) { if (n == 0) throw Error(JSON.stringify(l)); return { l: [...l, 0] } }",
},
}, {
"id": "b",
@@ -2267,7 +2284,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
},
"type": "rawscript",
"language": "deno",
"content": "export function main(n, l) { if (n == 1) throw l; return { l: [...l, 1] } }",
"content": "export function main(n, l) { if (n == 1) throw Error(JSON.stringify(l)); return { l: [...l, 1] } }",
},
}, {
"value": {
@@ -2277,7 +2294,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
},
"type": "rawscript",
"language": "deno",
"content": "export function main(n, l) { if (n == 2) throw l; return { l: [...l, 2] } }",
"content": "export function main(n, l) { if (n == 2) throw Error(JSON.stringify(l)); return { l: [...l, 2] } }",
},
}],
"failure_module": {
@@ -2297,10 +2314,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
.await
.result
.unwrap();
assert!(result["from failure module"]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("Uncaught (in promise) []"));
.contains("[]"));
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
.arg("n", json!(1))
@@ -2308,10 +2330,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
.await
.result
.unwrap();
assert!(result["from failure module"]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("Uncaught (in promise) [ 0 ]"));
.contains("[0]"));
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
.arg("n", json!(2))
@@ -2319,10 +2346,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
.await
.result
.unwrap();
assert!(result["from failure module"]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
.contains("Uncaught (in promise) [ 0, 1 ]"));
.contains("[0,1]"));
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
.arg("n", json!(3))

View File

@@ -69,3 +69,6 @@ hmac.workspace = true
cookie.workspace = true
sha2.workspace = true
urlencoding.workspace = true
async-stripe.workspace = true
lazy_static.workspace = true
prometheus.workspace = true

View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
npx @redocly/openapi-cli@latest bundle openapi.yaml > openapi-bundled.yaml
npx @redocly/openapi-cli@latest bundle openapi-bundled.yaml --ext yaml -d > openapi-deref.yaml
rm openapi-bundled.yaml

View File

View File

@@ -1,10 +1,11 @@
openapi: "3.0.3"
info:
version: 1.57.0
title: Windmill server API
version: 1.62.0
title: Windmill API
contact:
name: Windmill contact
name: Windmill Team
email: contact@windmill.dev
url: https://windmill.dev
@@ -12,6 +13,8 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
x-logo:
url: https://docs.windmill.dev/img/windmill.svg
externalDocs:
description: documentation portal
url: https://docs.windmill.dev
@@ -670,6 +673,43 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/add_user:
post:
summary: add user to workspace
operationId: addUser
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: WorkspaceInvite
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
is_admin:
type: boolean
username:
type: string
operator:
type: boolean
required:
- email
- is_admin
- operator
- username
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/delete_invite:
post:
summary: delete user invite
@@ -704,9 +744,41 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/delete:
/w/{workspace}/workspaces/archive:
post:
summary: archive workspace
operationId: archiveWorkspace
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/workspaces/unarchive/{workspace}:
post:
summary: unarchive workspace
operationId: unarchiveWorkspace
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/workspaces/delete/{workspace}:
delete:
summary: delete workspace
summary: delete workspace (require super admin)
operationId: deleteWorkspace
tags:
- workspace
@@ -807,6 +879,12 @@ paths:
type: string
auto_invite_operator:
type: boolean
plan:
type: string
customer_id:
type: string
webhook:
type: string
/w/{workspace}/workspaces/premium_info:
get:
@@ -886,6 +964,33 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_webhook:
post:
summary: edit webhook
operationId: editWebhook
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: WorkspaceWebhook
required: true
content:
application/json:
schema:
type: object
properties:
webhook:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/users/list:
get:
summary: list users
@@ -1436,7 +1541,10 @@ paths:
required: true
content:
application/json:
schema: {}
schema:
type: object
properties:
value: {}
responses:
"200":
description: resource value updated
@@ -1766,6 +1874,75 @@ paths:
flow:
$ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow"
/apps/hub/list:
get:
summary: list all available hub apps
operationId: listHubApps
tags:
- app
responses:
"200":
description: hub apps list
content:
application/json:
schema:
type: object
properties:
apps:
type: array
items:
type: object
properties:
id:
type: number
app_id:
type: number
summary:
type: string
apps:
type: array
items:
type: string
approved:
type: boolean
votes:
type: number
required:
- id
- app_id
- summary
- apps
- approved
- votes
/apps/hub/get/{id}:
get:
summary: get hub app by id
operationId: getHubAppById
tags:
- app
parameters:
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: app
content:
application/json:
schema:
type: object
properties:
app:
type: object
properties:
summary:
type: string
value: {}
required:
- summary
- value
required:
- app
/scripts/hub/get/{path}:
get:
summary: get hub script content by path
@@ -1900,6 +2077,25 @@ paths:
items:
$ref: "#/components/schemas/Script"
/w/{workspace}/scripts/list_paths:
get:
summary: list all available scripts paths
operationId: listScriptPaths
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of script paths
content:
text/plain:
schema:
type: array
items:
type: string
/w/{workspace}/scripts/create:
post:
summary: create script
@@ -2261,6 +2457,45 @@ paths:
type: integer
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
requestBody:
description: script args
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ScriptArgs"
responses:
"200":
description: job result
content:
application/json:
schema: {}
/w/{workspace}/jobs/run_wait_result/f/{path}:
post:
summary: run flow by path and wait until completion
operationId: runWaitResultFlowByPath
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- name: scheduled_for
description: when to schedule this job (leave empty for immediate run)
in: query
schema:
type: string
format: date-time
- name: scheduled_in_secs
description: schedule the script to execute in the number of seconds starting now
in: query
schema:
type: integer
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
requestBody:
description: script args
@@ -2307,6 +2542,24 @@ paths:
application/json:
schema: {}
/w/{workspace}/flows/list_paths:
get:
summary: list all available flow paths
operationId: listFlowPaths
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of flow paths
content:
text/plain:
schema:
type: array
items:
type: string
/w/{workspace}/flows/list:
get:
summary: list all available flows
@@ -2528,7 +2781,7 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps/public_app/{path}:
/w/{workspace}/apps_u/public_app/{path}:
get:
summary: get public app by secret
operationId: getPublicAppBySecret
@@ -2665,7 +2918,7 @@ paths:
schema:
type: string
/w/{workspace}/apps/execute_component/{path}:
/w/{workspace}/apps_u/execute_component/{path}:
post:
summary: executeComponent
operationId: executeComponent
@@ -2882,7 +3135,8 @@ paths:
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/Success"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/Running"
responses:
"200":
description: All available queued jobs
@@ -2972,7 +3226,7 @@ paths:
items:
$ref: "#/components/schemas/Job"
/w/{workspace}/jobs/get/{id}:
/w/{workspace}/jobs_u/get/{id}:
get:
summary: get job
operationId: getJob
@@ -3006,7 +3260,7 @@ paths:
# schema:
# type: string
/w/{workspace}/jobs/getupdate/{id}:
/w/{workspace}/jobs_u/getupdate/{id}:
get:
summary: get job updates
operationId: getJobUpdates
@@ -3038,6 +3292,8 @@ paths:
type: boolean
new_logs:
type: string
mem_peak:
type: integer
/w/{workspace}/jobs/completed/get/{id}:
get:
@@ -3164,7 +3420,7 @@ paths:
- resume
- cancel
/w/{workspace}/jobs/resume/{id}/{resume_id}/{signature}:
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
operationId: resumeSuspendedJobGet
@@ -3254,7 +3510,7 @@ paths:
schema:
type: string
/w/{workspace}/jobs/cancel/{id}/{resume_id}/{signature}:
/w/{workspace}/jobs_u/cancel/{id}/{resume_id}/{signature}:
get:
summary: cancel a job for a suspended flow
operationId: cancelSuspendedJobGet
@@ -3321,7 +3577,7 @@ paths:
schema:
type: string
/w/{workspace}/jobs/get_flow/{id}/{resume_id}/{signature}:
/w/{workspace}/jobs_u/get_flow/{id}/{resume_id}/{signature}:
get:
summary: get parent flow job of suspended job
operationId: getSuspendedJobFlow
@@ -4086,6 +4342,20 @@ paths:
schema:
type: string
/w/{workspace}/capture_u/{path}:
post:
summary: update flow preview capture
operationId: updateCapture
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"204":
description: flow preview captured
/w/{workspace}/capture/{path}:
put:
summary: create flow preview capture
@@ -4098,17 +4368,6 @@ paths:
responses:
"201":
description: flow preview capture created
post:
summary: update flow preview capture
operationId: updateCapture
tags:
- capture
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"204":
description: flow preview captured
get:
summary: get flow preview capture
operationId: getCapture
@@ -4276,6 +4535,13 @@ components:
in: query
schema:
type: string
QueueLimit:
name: queue_limit
description: |
The maximum size of the queue for which the request would get rejected if that job would push it above that limit
in: query
schema:
type: string
ScriptStartPath:
name: script_path_start
@@ -4315,6 +4581,18 @@ components:
in: query
schema:
type: boolean
Suspended:
name: suspended
description: filter on suspended jobs
in: query
schema:
type: boolean
Running:
name: running
description: filter on running jobs
in: query
schema:
type: boolean
After:
name: after
description: filter on created after (exclusive) timestamp
@@ -4514,6 +4792,8 @@ components:
type: string
visible_to_owner:
type: boolean
mem_peak:
type: integer
required:
- id
- running
@@ -4600,6 +4880,8 @@ components:
type: string
visible_to_owner:
type: boolean
mem_peak:
type: integer
required:
- id
- created_by
@@ -5396,6 +5678,8 @@ components:
enum: [viewer, publisher, anonymous]
on_behalf_of:
type: string
on_behalf_of_email:
type: string
ListableApp:
type: object

View File

@@ -12,14 +12,16 @@ use crate::{
jobs::script_path_to_payload,
users::{require_owner_of_path, Authed, OptAuthed},
variables::build_crypt,
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
extract::{Extension, Json, Path, Query},
routing::{delete, get, post},
Json, Router,
Router,
};
use hyper::StatusCode;
use magic_crypt::MagicCryptTrait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
@@ -30,8 +32,10 @@ use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
apps::ListAppQuery,
error::{to_anyhow, Error, JsonResult, Result},
users::owner_to_token_owner,
utils::{not_found_if_none, paginate, Pagination, StripPath},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath,
},
};
use windmill_queue::{push, JobPayload, RawCode};
@@ -53,6 +57,12 @@ pub fn unauthed_service() -> Router {
.route("/public_app/:secret", get(get_public_app_by_secret))
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/:id", get(get_hub_app_by_id))
}
#[derive(FromRow, Deserialize, Serialize)]
pub struct ListableApp {
pub id: i64,
@@ -128,10 +138,10 @@ pub struct EditApp {
async fn list_apps(
authed: Authed,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
) -> JsonResult<Vec<ListableApp>> {
let (per_page, offset) = paginate(pagination);
@@ -258,6 +268,15 @@ async fn get_public_app_by_secret(
tx.commit().await?;
let app = not_found_if_none(app_o, "App", id.to_string())?;
let policy = serde_json::from_value::<Policy>(app.policy.clone()).map_err(to_anyhow)?;
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access".to_string(),
));
}
Ok(Json(app))
}
@@ -292,6 +311,7 @@ async fn get_secret_id(
async fn create_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
@@ -338,14 +358,51 @@ async fn create_app(
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateApp { workspace: w_id, path: app.path.clone() },
);
Ok((StatusCode::CREATED, app.path))
}
async fn list_hub_apps(
Authed { email, .. }: Authed,
Extension(http_client): Extension<Client>,
) -> JsonResult<serde_json::Value> {
let flows = list_elems_from_hub(
http_client,
"https://hub.windmill.dev/searchUiData?approved=true",
&email,
)
.await?;
Ok(Json(flows))
}
pub async fn get_hub_app_by_id(
Authed { email, .. }: Authed,
Path(id): Path<i32>,
Extension(http_client): Extension<Client>,
) -> JsonResult<serde_json::Value> {
let value = http_get_from_hub(
http_client,
&format!("https://hub.windmill.dev/apps/{id}/json"),
&email,
false,
)
.await?
.json()
.await
.map_err(to_anyhow)?;
Ok(Json(value))
}
async fn delete_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -369,6 +426,10 @@ async fn delete_app(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::DeleteApp { workspace: w_id, path: path.to_owned() },
);
Ok(format!("app {} deleted", path))
}
@@ -376,6 +437,7 @@ async fn delete_app(
async fn update_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
@@ -465,6 +527,14 @@ async fn update_app(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateApp {
workspace: w_id,
old_path: path.to_owned(),
new_path: npath.clone(),
},
);
Ok(format!("app {} updated (npath: {:?})", path, npath))
}
@@ -494,12 +564,12 @@ async fn execute_component(
Json(payload): Json<ExecuteApp>,
) -> Result<String> {
match (payload.path.is_some(), payload.raw_code.is_some()) {
(true, true) => {
(false, false) => {
return Err(Error::BadRequest(
"path or raw_code is required".to_string(),
))
}
(false, false) => {
(true, true) => {
return Err(Error::BadRequest(
"path and raw_code cannot be set at the same time".to_string(),
))
@@ -510,18 +580,6 @@ async fn execute_component(
let path = path.to_path();
let mut tx = db.begin().await?;
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut tx)
.await?;
let policy = not_found_if_none(policy_o, "App", path)?;
let policy = serde_json::from_value::<Policy>(policy).map_err(to_anyhow)?;
let policy = if let Some(static_fields) = payload.clone().force_viewer_static_fields {
let mut hm = HashMap::new();
if let Some(path) = payload.path.clone() {
@@ -539,7 +597,17 @@ async fn execute_component(
on_behalf_of_email: None,
}
} else {
policy
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut tx)
.await?;
let policy = not_found_if_none(policy_o, "App", path)?;
serde_json::from_value::<Policy>(policy).map_err(to_anyhow)?
};
let (username, permissioned_as, email) = match policy.execution_mode {
@@ -563,7 +631,7 @@ async fn execute_component(
})?;
(
username.clone(),
owner_to_token_owner(&username, false),
username_to_permissioned_as(&username),
email,
)
}
@@ -633,7 +701,7 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"on_behalf_of is missing in the app policy and is required for anonymous execution"
"on_behalf_of_email is missing in the app policy and is required for anonymous execution"
.to_string(),
)
})?

View File

@@ -32,6 +32,7 @@ use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
};
pub fn workspaced_service() -> Router {
@@ -42,6 +43,7 @@ pub fn workspaced_service() -> Router {
.route("/archive/*path", post(archive_flow_by_path))
.route("/get/*path", get(get_flow_by_path))
.route("/exists/*path", get(exists_flow_by_path))
.route("/list_paths", get(list_paths))
}
pub fn global_service() -> Router {
@@ -122,6 +124,24 @@ async fn list_hub_flows(
Ok(Json(flows))
}
async fn list_paths(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<String>> {
let mut tx = user_db.begin(&authed).await?;
let flows = sqlx::query_scalar!(
"SELECT distinct(path) FROM flow WHERE workspace_id = $1",
w_id
)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(flows))
}
pub async fn get_hub_flow_by_id(
Authed { email, .. }: Authed,
Path(id): Path<i32>,
@@ -162,6 +182,7 @@ async fn check_path_conflict<'c>(
async fn create_flow(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(nf): Json<NewFlow>,
) -> Result<(StatusCode, String)> {
@@ -202,6 +223,10 @@ async fn create_flow(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() },
);
let tx = user_db.begin(&authed).await?;
let (dependency_job_uuid, mut tx) = push(
@@ -211,7 +236,7 @@ async fn create_flow(
serde_json::Map::new(),
&authed.username,
&authed.email,
windmill_common::users::owner_to_token_owner(&authed.username, false),
windmill_common::users::username_to_permissioned_as(&authed.username),
None,
None,
None,
@@ -261,6 +286,7 @@ async fn update_flow(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Json(nf): Json<NewFlow>,
) -> Result<String> {
@@ -280,13 +306,13 @@ async fn update_flow(
let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?;
sqlx::query!(
"UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, \
edited_at = now(), schema = $6, dependency_job = NULL WHERE path = $7 AND workspace_id = $8",
edited_at = now(), schema = $6::text::json, dependency_job = NULL WHERE path = $7 AND workspace_id = $8",
nf.path,
nf.summary,
nf.description,
nf.value,
&authed.username,
schema,
schema.and_then(|x| serde_json::to_string(&x).ok()),
flow_path,
w_id,
)
@@ -349,6 +375,14 @@ async fn update_flow(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFlow {
workspace: w_id.clone(),
old_path: flow_path.to_owned(),
new_path: nf.path.clone(),
},
);
let tx = user_db.begin(&authed).await?;
let (dependency_job_uuid, mut tx) = push(
@@ -358,7 +392,7 @@ async fn update_flow(
serde_json::Map::new(),
&authed.username,
&authed.email,
windmill_common::users::owner_to_token_owner(&authed.username, false),
windmill_common::users::username_to_permissioned_as(&authed.username),
None,
None,
None,
@@ -431,6 +465,7 @@ async fn exists_flow_by_path(
async fn archive_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -455,6 +490,10 @@ async fn archive_flow_by_path(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::ArchiveFlow { workspace: w_id, path: path.to_owned() },
);
Ok(format!("Flow {path} archived"))
}

View File

@@ -9,6 +9,7 @@
use crate::{
db::{UserDB, DB},
users::Authed,
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
@@ -19,7 +20,7 @@ use itertools::Itertools;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{self, Error, JsonResult, Result},
users::owner_to_token_owner,
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
@@ -139,13 +140,14 @@ async fn check_name_conflict<'c>(
async fn create_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
check_name_conflict(&mut tx, &w_id, &ng.name).await?;
let owner = owner_to_token_owner(&authed.username, false);
let owner = username_to_permissioned_as(&authed.username);
let owners = &ng.owners.unwrap_or(vec![owner.clone()]);
if let Some(extra_perms) = ng.extra_perms.clone() {
@@ -193,8 +195,12 @@ async fn create_folder(
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFolder { workspace: w_id, name: ng.name.clone() },
);
Ok(format!("Created folder {}", ng.name))
}
@@ -245,6 +251,7 @@ pub async fn require_is_owner(
async fn update_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(ng): Json<UpdateFolder>,
) -> Result<String> {
@@ -298,8 +305,12 @@ async fn update_folder(
None,
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.to_owned() },
);
Ok(format!("Updated folder {}", name))
}
@@ -416,6 +427,7 @@ async fn get_folder_usage(
async fn delete_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
@@ -440,6 +452,12 @@ async fn delete_folder(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("delete folder at name {}", name))
}
@@ -447,6 +465,7 @@ async fn add_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner }): Json<Owner>,
) -> Result<String> {
@@ -477,6 +496,12 @@ async fn add_owner(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Added {} to folder {}", owner, name))
}
@@ -510,6 +535,7 @@ async fn remove_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner }): Json<Owner>,
) -> Result<String> {
@@ -540,5 +566,11 @@ async fn remove_owner(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
Ok(format!("Removed {} to folder {}", owner, name))
}

View File

@@ -16,9 +16,9 @@ use axum::{
Json, Router,
};
use windmill_audit::{audit_log, ActionKind};
use windmill_common::users::username_to_permissioned_as;
use windmill_common::{
error::{Error, JsonResult, Result},
users::owner_to_token_owner,
utils::{not_found_if_none, paginate, Pagination},
};
@@ -199,7 +199,17 @@ async fn create_group(
w_id,
ng.name,
ng.summary,
serde_json::json!({owner_to_token_owner(&authed.username, false): true})
serde_json::json!({username_to_permissioned_as(&authed.username): true})
)
.execute(&mut tx)
.await?;
sqlx::query_as!(
Group,
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
&w_id,
&authed.username,
ng.name,
)
.execute(&mut tx)
.await?;
@@ -282,7 +292,9 @@ async fn delete_group(
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
sqlx::query!(
@@ -321,8 +333,9 @@ async fn update_group(
Json(eg): Json<EditGroup>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
sqlx::query_as!(
@@ -357,8 +370,9 @@ async fn add_user(
Json(Username { username: user_username }): Json<Username>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
@@ -394,7 +408,9 @@ async fn remove_user(
Json(Username { username: user_username }): Json<Username>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
if &name == "all" {

View File

@@ -10,16 +10,17 @@ use std::sync::Arc;
use anyhow::Context;
use axum::{
extract::{FromRequest, Path, Query},
extract::{FromRequest, Json, Path, Query},
response::{IntoResponse, Response},
routing::{get, post},
Extension, Json, Router,
Extension, Router,
};
use base64::Engine;
use hmac::Mac;
use hyper::{HeaderMap, StatusCode};
use hyper::{HeaderMap, Request, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sql_builder::{prelude::*, quote, SqlBuilder};
use sqlx::{query_scalar, types::Uuid, Postgres, Transaction};
use sqlx::{query_scalar, types::Uuid, FromRow, Postgres, Transaction};
use urlencoding::encode;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
@@ -28,7 +29,7 @@ use windmill_common::{
flows::FlowValue,
oauth2::HmacSha256,
scripts::{ScriptHash, ScriptLang},
users::owner_to_token_owner,
users::username_to_permissioned_as,
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
};
use windmill_queue::{get_queued_job, push, JobKind, JobPayload, QueuedJob, RawCode};
@@ -37,7 +38,7 @@ use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed},
variables::get_workspace_key,
BaseUrl,
BaseUrl, QueueLimitWaitResult, TimeoutWaitResult,
};
pub fn workspaced_service() -> Router {
@@ -52,6 +53,10 @@ pub fn workspaced_service() -> Router {
"/run_wait_result/h/:hash",
post(run_wait_result_job_by_hash),
)
.route(
"/run_wait_result/f/*script_path",
post(run_wait_result_flow_by_path),
)
.route("/run/h/:hash", post(run_job_by_hash))
.route("/run/preview", post(run_preview_job))
.route("/run/preview_flow", post(run_preview_flow_job))
@@ -62,9 +67,7 @@ pub fn workspaced_service() -> Router {
.route("/completed/get/:id", get(get_completed_job))
.route("/completed/get_result/:id", get(get_completed_job_result))
.route("/completed/delete/:id", post(delete_completed_job))
.route("/get/:id", get(get_job))
.route("/flow/resume/:id", post(resume_suspended_flow_as_owner))
.route("/getupdate/:id", get(get_job_update))
.route(
"/job_signature/:job_id/:resume_id",
get(create_job_signature),
@@ -95,6 +98,8 @@ pub fn global_service() -> Router {
"/get_flow/:job_id/:resume_id/:secret",
get(get_suspended_job_flow),
)
.route("/get/:id", get(get_job))
.route("/getupdate/:id", get(get_job_update))
}
async fn get_result_by_id(
@@ -215,32 +220,46 @@ pub async fn get_job_by_id<'c>(
pub struct CompletedJob {
pub workspace_id: String,
pub id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_job: Option<Uuid>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: chrono::DateTime<chrono::Utc>,
pub duration_ms: i32,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub script_hash: Option<ScriptHash>,
#[serde(skip_serializing_if = "Option::is_none")]
pub script_path: Option<String>,
pub args: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logs: Option<String>,
pub deleted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_code: Option<String>,
pub canceled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub canceled_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canceled_reason: Option<String>,
pub job_kind: JobKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_path: Option<String>,
pub permissioned_as: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_status: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_flow: Option<serde_json::Value>,
pub is_flow_step: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<ScriptLang>,
pub is_skipped: bool,
pub email: String,
pub visible_to_owner: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_peak: Option<i32>,
}
#[derive(Deserialize, Clone)]
@@ -250,6 +269,15 @@ pub struct RunJobQuery {
parent_job: Option<Uuid>,
include_header: Option<String>,
invisible_to_owner: Option<bool>,
queue_limit: Option<i64>,
}
lazy_static::lazy_static! {
static ref INCLUDE_HEADERS: Vec<String> = std::env::var("INCLUDE_HEADERS")
.ok().map(|x| x
.split(',')
.map(|s| s.to_string())
.collect()).unwrap_or_default();
}
impl RunJobQuery {
@@ -272,11 +300,14 @@ impl RunJobQuery {
headers: HeaderMap,
mut args: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
self.include_header
let whitelist = self
.include_header
.as_ref()
.map(|s| s.split(",").map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_default()
.unwrap_or_default();
whitelist
.iter()
.chain(INCLUDE_HEADERS.iter())
.for_each(|h| {
if let Some(v) = headers.get(h) {
args.insert(
@@ -336,11 +367,12 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
if let Some(dt) = &lq.created_after {
sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp()));
}
if let Some(s) = &lq.suspended {
if *s {
sqlb.and_where_is_not_null("suspend");
sqlb.and_where_gt("suspend", 0);
} else {
sqlb.and_where_is_null("suspend");
sqlb.and_where_eq("suspend", 0);
}
}
if let Some(jk) = &lq.job_kinds {
@@ -353,13 +385,55 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
sqlb
}
#[derive(Serialize, FromRow)]
struct ListableQueuedJob {
pub id: Uuid,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_for: chrono::DateTime<chrono::Utc>,
pub script_hash: Option<ScriptHash>,
pub script_path: Option<String>,
pub args: Option<serde_json::Value>,
pub job_kind: JobKind,
pub schedule_path: Option<String>,
pub is_flow_step: bool,
pub language: Option<ScriptLang>,
pub email: String,
pub suspend: Option<i32>,
}
async fn list_queue_jobs(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(lq): Query<ListQueueQuery>,
) -> error::JsonResult<Vec<QueuedJob>> {
let sql = list_queue_jobs_query(&w_id, &lq, &["*"]).sql()?;
let jobs = sqlx::query_as::<_, QueuedJob>(&sql).fetch_all(&db).await?;
) -> error::JsonResult<Vec<ListableQueuedJob>> {
let sql = list_queue_jobs_query(
&w_id,
&lq,
&[
"id",
"created_by",
"created_at",
"started_at",
"scheduled_for",
"script_hash",
"script_path",
"args",
"job_kind",
"schedule_path",
"permissioned_as",
"is_flow_step",
"language",
"same_worker",
"email",
"suspend",
],
)
.sql()?;
let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql)
.fetch_all(&db)
.await?;
Ok(Json(jobs))
}
@@ -400,7 +474,7 @@ async fn list_jobs(
"running",
"script_hash",
"script_path",
"args",
"CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END",
"null as duration_ms",
"null as success",
"false as deleted",
@@ -409,13 +483,13 @@ async fn list_jobs(
"job_kind",
"schedule_path",
"permissioned_as",
"flow_status",
"is_flow_step",
"language",
"false as is_skipped",
"email",
"visible_to_owner",
"suspend",
"mem_peak",
],
);
let sqlc = list_completed_jobs_query(
@@ -435,7 +509,7 @@ async fn list_jobs(
"null as running",
"script_hash",
"script_path",
"args",
"CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END",
"duration_ms",
"success",
"deleted",
@@ -444,13 +518,13 @@ async fn list_jobs(
"job_kind",
"schedule_path",
"permissioned_as",
"flow_status",
"is_flow_step",
"language",
"is_skipped",
"email",
"visible_to_owner",
"null as suspend",
"mem_peak",
],
);
let sql = format!(
@@ -499,8 +573,8 @@ pub async fn resume_suspended_job(
/* unauthed */
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
Query(approver): Query<QueryApprover>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
let value = value.unwrap_or(serde_json::Value::Null);
let mut tx = db.begin().await?;
@@ -843,7 +917,7 @@ fn build_resume_url(
approver: &str,
base_url: &str,
) -> String {
format!("{base_url}/api/w/{w_id}/jobs/{op}/{job_id}/{resume_id}/{signature}{approver}")
format!("{base_url}/api/w/{w_id}/jobs_u/{op}/{job_id}/{resume_id}/{signature}{approver}")
}
pub async fn get_resume_urls(
@@ -922,13 +996,13 @@ struct UnifiedJob {
job_kind: JobKind,
schedule_path: Option<String>,
permissioned_as: String,
flow_status: Option<serde_json::Value>,
is_flow_step: bool,
language: Option<ScriptLang>,
is_skipped: bool,
email: String,
visible_to_owner: bool,
suspend: Option<i32>,
mem_peak: Option<i32>,
}
impl From<UnifiedJob> for Job {
@@ -948,6 +1022,7 @@ impl From<UnifiedJob> for Job {
args: uj.args,
result: None,
logs: None,
flow_status: None,
deleted: uj.deleted,
canceled: uj.canceled,
canceled_by: uj.canceled_by,
@@ -956,13 +1031,13 @@ impl From<UnifiedJob> for Job {
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
flow_status: uj.flow_status,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
is_skipped: uj.is_skipped,
email: uj.email,
visible_to_owner: uj.visible_to_owner,
mem_peak: uj.mem_peak,
}),
"QueuedJob" => Job::QueuedJob(QueuedJob {
workspace_id: uj.workspace_id,
@@ -977,6 +1052,7 @@ impl From<UnifiedJob> for Job {
running: uj.running.unwrap(),
scheduled_for: uj.scheduled_for.unwrap(),
logs: None,
flow_status: None,
raw_code: None,
raw_lock: None,
canceled: uj.canceled,
@@ -986,7 +1062,6 @@ impl From<UnifiedJob> for Job {
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
flow_status: uj.flow_status,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
@@ -995,6 +1070,7 @@ impl From<UnifiedJob> for Job {
email: uj.email,
visible_to_owner: uj.visible_to_owner,
suspend: uj.suspend,
mem_peak: uj.mem_peak,
}),
t => panic!("job type {} not valid", t),
}
@@ -1023,20 +1099,19 @@ struct PreviewFlow {
pub struct QueryOrBody<D>(pub Option<D>);
#[axum::async_trait]
impl<D, B> FromRequest<B> for QueryOrBody<D>
impl<S, D> FromRequest<S, axum::body::Body> for QueryOrBody<D>
where
D: DeserializeOwned,
B: Send + axum::body::HttpBody,
<B as axum::body::HttpBody>::Data: Send,
<B as axum::body::HttpBody>::Error: Into<axum::BoxError>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(
req: &mut axum::extract::RequestParts<B>,
req: Request<axum::body::Body>,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
return if req.method() == axum::http::Method::GET {
let Query(InPayload { payload }) = Query::from_request(req)
let Query(InPayload { payload }) = Query::from_request(req, state)
.await
.map_err(IntoResponse::into_response)?;
payload
@@ -1048,7 +1123,7 @@ where
})
.unwrap_or(Ok(QueryOrBody(None)))
} else {
Json::from_request(req)
Json::from_request(req, state)
.await
.map(|Json(v)| QueryOrBody(Some(v)))
.map_err(IntoResponse::into_response)
@@ -1060,7 +1135,9 @@ where
}
fn decode_payload<D: DeserializeOwned, T: AsRef<[u8]>>(t: T) -> anyhow::Result<D> {
let vec = base64::decode_config(&t, base64::URL_SAFE).context("invalid base64")?;
let vec = base64::engine::general_purpose::URL_SAFE
.decode(t)
.context("invalid base64")?;
serde_json::from_slice(vec.as_slice()).context("invalid json")
}
}
@@ -1069,9 +1146,9 @@ pub async fn run_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::Result<(StatusCode, String)> {
let flow_path = flow_path.to_path();
let mut tx = user_db.begin(&authed).await?;
@@ -1085,7 +1162,7 @@ pub async fn run_flow_by_path(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
@@ -1103,9 +1180,9 @@ pub async fn run_job_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::Result<(StatusCode, String)> {
let script_path = script_path.to_path();
let mut tx = user_db.begin(&authed).await?;
@@ -1120,7 +1197,7 @@ pub async fn run_job_by_path(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
@@ -1134,16 +1211,66 @@ pub async fn run_job_by_path(
Ok((StatusCode::CREATED, uuid.to_string()))
}
struct Guard {
done: bool,
id: Uuid,
w_id: String,
db: UserDB,
authed: Authed,
}
impl Drop for Guard {
fn drop(&mut self) {
if !&self.done {
let id = self.id;
let username = self.authed.username.clone();
let w_id = self.w_id.clone();
let db = self.db.clone();
let authed = self.authed.clone();
tracing::info!("http connection broke, marking job {id} as canceled");
tokio::spawn(async move {
let tx = db.begin(&authed).await.ok();
if let Some(mut tx) = tx {
let _ = sqlx::query!(
"UPDATE queue SET canceled = true, canceled_reason = 'http connection broke', canceled_by = $1 WHERE id = $2 AND workspace_id = $3",
username,
id,
w_id
)
.execute(&mut tx)
.await;
let _ = tx.commit().await;
}
});
}
}
}
async fn run_wait_result<T>(
authed: Authed,
Extension(user_db): Extension<UserDB>,
timeout: i32,
uuid: Uuid,
Path((w_id, _)): Path<(String, T)>,
) -> error::JsonResult<serde_json::Value> {
let mut result = None;
for i in 0..48 {
let iters = if timeout <= 0 {
20
} else if timeout <= 1 {
timeout * 10
} else {
10 + ((timeout - 1) * 2)
};
let mut g = Guard {
done: false,
id: uuid,
w_id: w_id.clone(),
db: user_db.clone(),
authed: authed.clone(),
};
for i in 0..iters {
let mut tx = user_db.clone().begin(&authed).await?;
result = sqlx::query_scalar!(
"SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2",
uuid,
@@ -1152,6 +1279,7 @@ async fn run_wait_result<T>(
.fetch_optional(&mut tx)
.await?
.flatten();
drop(tx);
if result.is_some() {
break;
@@ -1160,20 +1288,45 @@ async fn run_wait_result<T>(
tokio::time::sleep(core::time::Duration::from_millis(delay)).await;
}
if let Some(result) = result {
g.done = true;
Ok(Json(result))
} else {
Err(Error::ExecutionErr("timeout after 20s".to_string()))
Err(Error::ExecutionErr(format!("timeout after {}s", timeout)))
}
}
pub async fn check_queue_too_long(db: DB, queue_limit: Option<i64>) -> error::Result<()> {
if let Some(limit) = queue_limit {
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM queue WHERE canceled = false AND (scheduled_for <= now()
OR (suspend_until IS NOT NULL
AND ( suspend <= 0
OR suspend_until <= now())))",
)
.fetch_one(&db)
.await?
.unwrap_or(0);
if count > queue_limit.unwrap() {
return Err(Error::InternalErr(format!(
"Number of queued job is too high: {count} > {limit}"
)));
}
}
Ok(())
}
pub async fn run_wait_result_job_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(timeout): Extension<Arc<TimeoutWaitResult>>,
Extension(queue_limit): Extension<Arc<QueueLimitWaitResult>>,
Path((w_id, script_path)): Path<(String, StripPath)>,
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::JsonResult<serde_json::Value> {
check_queue_too_long(db, queue_limit.0.or(run_query.queue_limit)).await?;
let script_path = script_path.to_path();
let mut tx = user_db.clone().begin(&authed).await?;
let job_payload = script_path_to_payload(script_path, &mut tx, &w_id).await?;
@@ -1188,7 +1341,7 @@ pub async fn run_wait_result_job_by_path(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
@@ -1200,17 +1353,28 @@ pub async fn run_wait_result_job_by_path(
.await?;
tx.commit().await?;
run_wait_result(authed, Extension(user_db), uuid, Path((w_id, script_path))).await
run_wait_result(
authed,
Extension(user_db),
timeout.0,
uuid,
Path((w_id, script_path)),
)
.await
}
pub async fn run_wait_result_job_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(timeout): Extension<Arc<TimeoutWaitResult>>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::JsonResult<serde_json::Value> {
check_queue_too_long(db, run_query.queue_limit).await?;
let hash = script_hash.0;
let mut tx = user_db.clone().begin(&authed).await?;
let path = get_path_for_hash(&mut tx, &w_id, hash).await?;
@@ -1224,7 +1388,7 @@ pub async fn run_wait_result_job_by_hash(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
@@ -1236,7 +1400,61 @@ pub async fn run_wait_result_job_by_hash(
.await?;
tx.commit().await?;
run_wait_result(authed, Extension(user_db), uuid, Path((w_id, script_hash))).await
run_wait_result(
authed,
Extension(user_db),
timeout.0,
uuid,
Path((w_id, script_hash)),
)
.await
}
pub async fn run_wait_result_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(timeout): Extension<Arc<TimeoutWaitResult>>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::JsonResult<serde_json::Value> {
check_queue_too_long(db, run_query.queue_limit).await?;
let flow_path = flow_path.to_path();
let mut tx = user_db.clone().begin(&authed).await?;
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
let args = run_query.add_include_headers(headers, args.unwrap_or_default());
let (uuid, tx) = push(
tx,
&w_id,
JobPayload::Flow(flow_path.to_string()),
args,
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
)
.await?;
tx.commit().await?;
run_wait_result(
authed,
Extension(user_db),
timeout.0,
uuid,
Path((w_id, flow_path)),
)
.await
}
// a similar function exists on the worker
@@ -1258,9 +1476,9 @@ async fn run_preview_job(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(preview): Json<Preview>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(preview): Json<Preview>,
) -> error::Result<(StatusCode, String)> {
let mut tx = user_db.begin(&authed).await?;
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
@@ -1278,7 +1496,7 @@ async fn run_preview_job(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
None,
@@ -1296,9 +1514,9 @@ async fn run_preview_flow_job(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(raw_flow): Json<PreviewFlow>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(raw_flow): Json<PreviewFlow>,
) -> error::Result<(StatusCode, String)> {
let mut tx = user_db.begin(&authed).await?;
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
@@ -1311,7 +1529,7 @@ async fn run_preview_flow_job(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
None,
@@ -1329,9 +1547,9 @@ pub async fn run_job_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::Result<(StatusCode, String)> {
let hash = script_hash.0;
let mut tx = user_db.begin(&authed).await?;
@@ -1346,7 +1564,7 @@ pub async fn run_job_by_hash(
args,
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
@@ -1371,6 +1589,7 @@ pub struct JobUpdate {
pub running: Option<bool>,
pub completed: Option<bool>,
pub new_logs: Option<String>,
pub mem_peak: Option<i32>,
}
async fn get_job_update(
@@ -1380,8 +1599,8 @@ async fn get_job_update(
) -> error::JsonResult<JobUpdate> {
let mut tx = db.begin().await?;
let logs = query_scalar!(
"SELECT substr(logs, $1) as logs FROM queue WHERE workspace_id = $2 AND id = $3",
let record = sqlx::query!(
"SELECT substr(logs, $1) as logs, mem_peak FROM queue WHERE workspace_id = $2 AND id = $3",
log_offset,
&w_id,
&id
@@ -1389,12 +1608,13 @@ async fn get_job_update(
.fetch_optional(&mut tx)
.await?;
if let Some(logs) = logs {
if let Some(record) = record {
tx.commit().await?;
Ok(Json(JobUpdate {
running: if !running { Some(true) } else { None },
completed: None,
new_logs: logs,
new_logs: record.logs,
mem_peak: record.mem_peak,
}))
} else {
let logs = query_scalar!(
@@ -1412,6 +1632,7 @@ async fn get_job_update(
running: Some(false),
completed: Some(true),
new_logs: logs,
mem_peak: record.map(|r| r.mem_peak).flatten(),
}))
}
}
@@ -1529,6 +1750,7 @@ async fn list_completed_jobs(
"is_skipped",
"email",
"visible_to_owner",
"mem_peak",
],
)
.sql()?;
@@ -1579,7 +1801,7 @@ async fn delete_completed_job(
require_admin(authed.is_admin, &authed.username)?;
let job_o = sqlx::query_as::<_, CompletedJob>(
"UPDATE completed_job SET logs = '', deleted = true WHERE id = $1 AND workspace_id = $2 \
"UPDATE completed_job SET logs = '', result = null, deleted = true WHERE id = $1 AND workspace_id = $2 \
RETURNING *",
)
.bind(id)

View File

@@ -7,7 +7,7 @@
*/
use argon2::Argon2;
use axum::{handler::Handler, middleware::from_extractor, routing::get, Extension, Router};
use axum::{middleware::from_extractor, routing::get, Extension, Router};
use db::DB;
use git_version::git_version;
use std::{net::SocketAddr, sync::Arc};
@@ -21,6 +21,7 @@ use crate::{
oauth2::{build_oauth_clients, SlackVerifier},
tracing_init::{MyMakeSpan, MyOnResponse},
users::{Authed, OptAuthed},
webhook_util::WebhookShared,
};
mod apps;
@@ -42,6 +43,7 @@ mod tracing_init;
mod users;
mod utils;
mod variables;
mod webhook_util;
mod worker_ping;
mod workspaces;
@@ -53,6 +55,8 @@ pub struct IsSecure(bool);
pub struct CookieDomain(Option<String>);
pub struct CloudHosted(bool);
pub struct ContentSecurityPolicy(String);
pub struct TimeoutWaitResult(i32);
pub struct QueueLimitWaitResult(Option<i64>);
pub use users::delete_expired_items_perdiodically;
@@ -91,20 +95,21 @@ pub async fn run_server(
.layer(Extension(auth_cache.clone()))
.layer(Extension(basic_clients))
.layer(Extension(Arc::new(BaseUrl(base_url.to_string()))))
.layer(Extension(Arc::new(ContentSecurityPolicy(
std::env::var("SERVE_CSP").unwrap_or("".to_owned()),
))))
.layer(Extension(Arc::new(CloudHosted(
std::env::var("CLOUD_HOSTED").is_ok(),
))))
.layer(Extension(Arc::new(IsSecure(
base_url.starts_with("https://"),
))))
.layer(Extension(Arc::new(ContentSecurityPolicy(
std::env::var("SERVE_CSP").unwrap_or("".to_owned()),
))))
.layer(Extension(Arc::new(CookieDomain(
std::env::var("COOKIE_DOMAIN").ok(),
))))
.layer(Extension(http_client))
.layer(CookieManagerLayer::new());
.layer(CookieManagerLayer::new())
.layer(Extension(WebhookShared::new(rx.resubscribe(), db.clone())));
// build our application with a route
let app = Router::new()
.nest(
@@ -114,7 +119,21 @@ pub async fn run_server(
"/w/:workspace_id",
Router::new()
.nest("/scripts", scripts::workspaced_service())
.nest("/jobs", jobs::workspaced_service())
.nest(
"/jobs",
jobs::workspaced_service()
.layer(Extension(Arc::new(TimeoutWaitResult(
std::env::var("TIMEOUT_WAIT_RESULT")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(20),
))))
.layer(Extension(Arc::new(QueueLimitWaitResult(
std::env::var("QUEUE_LIMIT_WAIT_RESULT")
.ok()
.and_then(|x| x.parse().ok()),
)))),
)
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),
@@ -127,9 +146,9 @@ pub async fn run_server(
.nest("/audit", audit::workspaced_service())
.nest("/acls", granular_acls::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/flows", flows::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/folders", folders::workspaced_service()),
)
@@ -141,15 +160,16 @@ pub async fn run_server(
.nest("/workers", worker_ping::global_service())
.nest("/scripts", scripts::global_service())
.nest("/flows", flows::global_service())
.nest("/apps", apps::global_service())
.nest("/schedules", schedule::global_service())
.route_layer(from_extractor::<Authed>())
.route_layer(from_extractor::<users::Tokened>())
.nest(
"/w/:workspace_id/apps",
"/w/:workspace_id/apps_u",
apps::unauthed_service().layer(from_extractor::<OptAuthed>()),
)
.nest("/w/:workspace_id/jobs", jobs::global_service())
.nest("/w/:workspace_id/capture", capture::global_service())
.nest("/w/:workspace_id/jobs_u", jobs::global_service())
.nest("/w/:workspace_id/capture_u", capture::global_service())
.nest(
"/auth",
users::make_unauthed_service().layer(Extension(argon2)),
@@ -161,7 +181,7 @@ pub async fn run_server(
.route("/version", get(git_v))
.route("/openapi.yaml", get(openapi)),
)
.fallback(static_assets::static_handler.into_service())
.fallback(static_assets::static_handler)
.layer(middleware_stack);
let instance_name = rd_string(5);
@@ -185,7 +205,7 @@ async fn git_v() -> &'static str {
}
async fn openapi() -> &'static str {
include_str!("../openapi.yaml")
include_str!("../openapi-deref.yaml")
}
pub async fn migrate_db(db: &DB) -> anyhow::Result<()> {
db::migrate(db).await?;

View File

@@ -11,10 +11,12 @@ use std::{collections::HashMap, fmt::Debug};
use std::sync::Arc;
use anyhow::Context;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::{
async_trait,
body::Bytes,
extract::{Extension, FromRequest, Path, Query, RequestParts},
extract::{Extension, Path, Query},
response::Redirect,
routing::{get, post},
Json, Router,
@@ -485,10 +487,10 @@ struct VariablePath {
async fn refresh_token(
authed: Authed,
Path((w_id, id)): Path<(String, i32)>,
Json(VariablePath { path }): Json<VariablePath>,
Extension(user_db): Extension<UserDB>,
Extension(clients): Extension<Arc<AllClients>>,
Extension(http_client): Extension<Client>,
Json(VariablePath { path }): Json<VariablePath>,
) -> error::Result<String> {
let tx = user_db.begin(&authed).await?;
@@ -606,9 +608,9 @@ pub struct OAuthCallback {
async fn connect_callback(
cookies: Cookies,
Path(client_name): Path<String>,
Json(callback): Json<OAuthCallback>,
Extension(clients): Extension<Arc<AllClients>>,
Extension(http_client): Extension<Client>,
Json(callback): Json<OAuthCallback>,
) -> error::JsonResult<TokenResponse> {
let client_w_scopes = &clients
.connects
@@ -628,10 +630,10 @@ async fn connect_slack_callback(
Path(w_id): Path<String>,
authed: Authed,
cookies: Cookies,
Json(callback): Json<OAuthCallback>,
Extension(user_db): Extension<UserDB>,
Extension(clients): Extension<Arc<AllClients>>,
Extension(http_client): Extension<Client>,
Json(callback): Json<OAuthCallback>,
) -> error::Result<String> {
let client = clients
.slack
@@ -656,19 +658,18 @@ async fn connect_slack_callback(
.execute(&mut tx)
.await?;
sqlx::query!(
"INSERT INTO group_
(workspace_id, name, summary)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
"INSERT INTO folder
(workspace_id, name, owners, extra_perms)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
&w_id,
"slack",
"The group that runs the script triggered by the slack /windmill command.
Share scripts to this group to make them executable from slack and add
members to this group to let them manage the slack related owner space."
"slack_bot",
&[],
serde_json::json!({})
)
.execute(&mut tx)
.await?;
let token_path = "g/slack/bot_token";
let token_path = "f/slack_bot/bot_token";
let mc = build_crypt(&mut tx, &w_id).await?;
let value = encrypt(&mc, &token.bot.bot_access_token);
sqlx::query!(
@@ -718,14 +719,17 @@ pub struct SlackSig {
}
#[async_trait]
impl<B> FromRequest<B> for SlackSig
impl<S> FromRequestParts<S> for SlackSig
where
B: Send,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
let hm = req.headers();
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let hm = &parts.headers;
Ok(Self {
sig: hm
.get("X-Slack-Signature")
@@ -834,13 +838,13 @@ pub struct UserInfo {
async fn login_callback(
Path(client_name): Path<String>,
Json(callback): Json<OAuthCallback>,
cookies: Cookies,
Extension(clients): Extension<Arc<AllClients>>,
Extension(db): Extension<DB>,
Extension(http_client): Extension<Client>,
Extension(is_secure): Extension<Arc<IsSecure>>,
Extension(cookie_domain): Extension<Arc<CookieDomain>>,
Json(callback): Json<OAuthCallback>,
) -> error::Result<String> {
let client_w_config = &clients
.logins

View File

@@ -9,6 +9,7 @@
use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
@@ -263,6 +264,7 @@ async fn check_path_conflict<'c>(
async fn create_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(resource): Json<CreateResource>,
) -> Result<(StatusCode, String)> {
@@ -293,6 +295,11 @@ async fn create_resource(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateResource { workspace: w_id, path: resource.path.clone() },
);
Ok((
StatusCode::CREATED,
format!("resource {} created", resource.path),
@@ -302,6 +309,7 @@ async fn create_resource(
async fn delete_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -333,12 +341,18 @@ async fn delete_resource(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteResource { workspace: w_id, path: path.to_owned() },
);
Ok(format!("resource {} deleted", path))
}
async fn update_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditResource>,
@@ -400,21 +414,36 @@ async fn update_resource(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateResource {
workspace: w_id,
old_path: path.to_owned(),
new_path: npath.clone(),
},
);
Ok(format!("resource {} updated (npath: {:?})", path, npath))
}
#[derive(FromRow, Serialize, Deserialize)]
struct UpdateResource {
value: Option<serde_json::Value>,
}
async fn update_resource_value(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(nv): Json<serde_json::Value>,
Json(nv): Json<UpdateResource>,
) -> Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"UPDATE resource SET value = $1 WHERE path = $2 AND workspace_id = $3",
nv,
nv.value,
path,
w_id
)
@@ -431,6 +460,14 @@ async fn update_resource_value(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateResource {
workspace: w_id,
old_path: path.to_owned(),
new_path: path.to_owned(),
},
);
Ok(format!("value of resource {} updated", path))
}
@@ -456,7 +493,7 @@ async fn list_resource_types_names(
Path(w_id): Path<String>,
) -> JsonResult<Vec<String>> {
let rows = sqlx::query_scalar!(
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') \
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') \
ORDER BY name",
&w_id
)
@@ -476,7 +513,7 @@ async fn get_resource_type(
let resource_type_o = sqlx::query_as!(
ResourceType,
"SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
'starter')",
'starter' OR workspace_id = 'admins')",
&name,
&w_id
)
@@ -493,7 +530,8 @@ async fn exists_resource_type(
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND workspace_id = $2)",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
'starter' OR workspace_id = 'admins'))",
name,
w_id
)
@@ -507,6 +545,7 @@ async fn exists_resource_type(
async fn create_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(resource_type): Json<CreateResourceType>,
) -> Result<(StatusCode, String)> {
@@ -537,6 +576,11 @@ async fn create_resource_type(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateResourceType { name: resource_type.name.clone() },
);
Ok((
StatusCode::CREATED,
format!("resource_type {} created", resource_type.name),
@@ -568,6 +612,7 @@ async fn check_rt_path_conflict<'c>(
async fn delete_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
@@ -592,6 +637,10 @@ async fn delete_resource_type(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteResourceType { name: name.clone() },
);
Ok(format!("resource_type {} deleted", name))
}
@@ -599,6 +648,7 @@ async fn delete_resource_type(
async fn update_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Json(ns): Json<EditResourceType>,
) -> Result<String> {
@@ -628,6 +678,10 @@ async fn update_resource_type(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateResourceType { name: name.clone() },
);
Ok(format!("resource_type {} updated", name))
}

View File

@@ -383,7 +383,7 @@ pub struct PreviewPayload {
}
fn get_offset(offset: Option<i32>) -> FixedOffset {
FixedOffset::west(offset.unwrap_or(0) * 60)
FixedOffset::west_opt(offset.unwrap_or(0) * 60).expect("Invalid offset")
}
#[derive(Deserialize)]

View File

@@ -14,6 +14,7 @@ use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
extract::{Extension, Path, Query},
@@ -36,7 +37,7 @@ use windmill_common::{
to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Script, ScriptHash,
ScriptKind, ScriptLang,
},
users::owner_to_token_owner,
users::username_to_permissioned_as,
utils::{
list_elems_from_hub, not_found_if_none, paginate, require_admin, Pagination, StripPath,
},
@@ -72,6 +73,7 @@ pub fn workspaced_service() -> Router {
.route("/get/h/:hash", get(get_script_by_hash))
.route("/raw/h/:hash", get(raw_script_by_hash))
.route("/deployment_status/h/:hash", get(get_deployment_status))
.route("/list_paths", get(list_paths))
}
async fn list_scripts(
authed: Authed,
@@ -183,6 +185,7 @@ fn hash_script(ns: &NewScript) -> i64 {
async fn create_script(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(ns): Json<NewScript>,
@@ -373,7 +376,7 @@ async fn create_script(
serde_json::Map::new(),
&authed.username,
&authed.email,
owner_to_token_owner(&authed.username, false),
username_to_permissioned_as(&authed.username),
None,
None,
None,
@@ -399,6 +402,14 @@ async fn create_script(
Some([("hash", hash.to_string().as_str())].into()),
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateScript {
workspace: w_id,
path: ns.path.clone(),
hash: hash.to_string(),
},
);
} else {
audit_log(
&mut tx,
@@ -416,6 +427,14 @@ async fn create_script(
),
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateScript {
workspace: w_id,
path: ns.path.clone(),
hash: hash.to_string(),
},
);
}
tx.commit().await?;
@@ -464,15 +483,30 @@ async fn get_script_by_path(
Ok(Json(script))
}
async fn list_paths(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<String>> {
let mut tx = user_db.begin(&authed).await?;
let scripts = sqlx::query_scalar!(
"SELECT distinct(path) FROM script WHERE workspace_id = $1",
w_id
)
.fetch_all(&mut tx)
.await?;
tx.commit().await?;
Ok(Json(scripts))
}
async fn raw_script_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path
.to_path()
.strip_suffix(".ts")
.ok_or_else(|| Error::BadRequest("Raw script path must end with .ts".to_string()))?;
let path = path.to_path().split(".").next().unwrap_or_default();
let mut tx = user_db.begin(&authed).await?;
let content_o = sqlx::query_scalar!(
@@ -584,6 +618,7 @@ async fn get_deployment_status(
async fn archive_script_by_path(
authed: Authed,
Extension(webhook): Extension<WebhookShared>,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -610,6 +645,10 @@ async fn archive_script_by_path(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() },
);
Ok(())
}
@@ -617,6 +656,7 @@ async fn archive_script_by_path(
async fn archive_script_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script> {
let mut tx = user_db.begin(&authed).await?;
@@ -641,12 +681,18 @@ async fn archive_script_by_hash(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() },
);
Ok(Json(script))
}
async fn delete_script_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script> {
@@ -675,6 +721,11 @@ async fn delete_script_by_hash(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteScript { workspace: w_id, hash: hash.to_string() },
);
Ok(Json(script))
}

View File

@@ -8,7 +8,8 @@
use axum::{
body::{self, BoxBody},
http::{header, response::Builder, Response, Uri},
extract::OriginalUri,
http::{header, response::Builder, Response},
response::IntoResponse,
Extension,
};
@@ -20,26 +21,28 @@ use std::sync::Arc;
// static_handler is a handler that serves static files from the
pub async fn static_handler(
uri: Uri,
Extension(is_secure): Extension<Arc<IsSecure>>,
Extension(is_cloud_hosted): Extension<Arc<CloudHosted>>,
Extension(csp): Extension<Arc<ContentSecurityPolicy>>,
) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/').to_string();
OriginalUri(original_uri): OriginalUri,
) -> StaticFile {
let path = original_uri.path().trim_start_matches('/').to_string();
StaticFile(path, is_secure.0, is_cloud_hosted.0, csp)
}
#[derive(RustEmbed)]
#[folder = "../../frontend/build/"]
struct Asset;
pub struct StaticFile<T>(pub T, pub bool, pub bool, pub Arc<ContentSecurityPolicy>);
pub struct StaticFile(
pub String,
pub bool,
pub bool,
pub Arc<ContentSecurityPolicy>,
);
impl<T> IntoResponse for StaticFile<T>
where
T: Into<String>,
{
impl IntoResponse for StaticFile {
fn into_response(self) -> Response<BoxBody> {
let path = self.0.into();
let path = self.0;
let can_set_security_headers = self.1 && self.2;
let csp = self.3;
serve_path(path, can_set_security_headers, csp)

View File

@@ -18,8 +18,8 @@ use crate::{
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
async_trait,
extract::{Extension, FromRequest, Path, Query, RequestParts},
http,
extract::{Extension, FromRequestParts, OriginalUri, Path, Query},
http::{self, request::Parts},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
@@ -177,7 +177,16 @@ impl AuthCache {
})
}
} else {
None
let groups = vec![];
let folders = vec![];
Some(Authed {
email: email
.unwrap_or_else(|| "missing@email.xyz".to_string()),
username: owner,
is_admin: super_admin,
groups,
folders,
})
}
}
(_, Some(email), super_admin) => {
@@ -275,16 +284,16 @@ impl AuthCache {
}
}
async fn extract_token<B: Send>(req: &mut RequestParts<B>) -> Option<String> {
let auth_header = req
.headers()
async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let from_cookie = match auth_header {
Some(x) => Some(x.to_owned()),
None => Extension::<Cookies>::from_request(req)
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
@@ -296,7 +305,7 @@ async fn extract_token<B: Send>(req: &mut RequestParts<B>) -> Option<String> {
}
match from_cookie {
Some(token) => Some(token),
None => Query::<Token>::from_request(req)
None => Query::<Token>::from_request_parts(parts, state)
.await
.ok()
.and_then(|token| token.token.clone()),
@@ -309,21 +318,24 @@ pub struct Tokened {
}
#[async_trait]
impl<B> FromRequest<B> for Tokened
impl<S> FromRequestParts<S> for Tokened
where
B: Send,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
let already_tokened = req.extensions().get::<Tokened>();
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(tokened.clone())
} else {
let token_o = extract_token(req).await;
let token_o = extract_token(parts, state).await;
if let Some(token) = token_o {
let tokened = Self { token };
req.extensions_mut().insert(tokened.clone());
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
@@ -342,33 +354,44 @@ pub struct Authed {
}
#[async_trait]
impl<B> FromRequest<B> for Authed
impl<S> FromRequestParts<S> for Authed
where
B: Send,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
let already_authed = req.extensions().get::<Authed>();
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let already_authed = parts.extensions.get::<Authed>();
if let Some(authed) = already_authed {
Ok(authed.clone())
} else {
let already_tokened = req.extensions().get::<Tokened>();
let already_tokened = parts.extensions.get::<Tokened>();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(req).await
extract_token(parts, state).await
};
let path_vec: Vec<&str> = req.uri().path().split("/").collect();
let workspace_id = if path_vec[0] == "" && path_vec[1] == "w" {
Some(path_vec[2].to_owned())
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else {
None
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) = Extension::<Arc<AuthCache>>::from_request(req).await {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
{
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
req.extensions_mut().insert(authed.clone());
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
@@ -388,14 +411,17 @@ where
pub struct OptAuthed(pub Option<Authed>);
#[async_trait]
impl<B> FromRequest<B> for OptAuthed
impl<S> FromRequestParts<S> for OptAuthed
where
B: Send,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
Authed::from_request(req)
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
Authed::from_request_parts(parts, state)
.await
.map(|authed| Self(Some(authed)))
.or_else(|_| Ok(Self(None)))

View File

@@ -12,6 +12,7 @@ use crate::{
db::{UserDB, DB},
oauth2::{AllClients, _refresh_token},
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
BaseUrl,
};
/*
@@ -224,6 +225,7 @@ async fn check_path_conflict<'c>(
async fn create_variable(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(variable): Json<CreateVariable>,
) -> Result<(StatusCode, String)> {
@@ -265,6 +267,11 @@ async fn create_variable(
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateVariable { workspace: w_id, path: variable.path.clone() },
);
Ok((
StatusCode::CREATED,
format!("variable {} created", variable.path),
@@ -274,6 +281,7 @@ async fn create_variable(
async fn delete_variable(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -306,6 +314,11 @@ async fn delete_variable(
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteVariable { workspace: w_id, path: path.to_owned() },
);
Ok(format!("variable {} deleted", path))
}
@@ -320,6 +333,7 @@ struct EditVariable {
async fn update_variable(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditVariable>,
@@ -405,6 +419,15 @@ async fn update_variable(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateVariable {
workspace: w_id,
old_path: path.to_owned(),
new_path: npath.clone(),
},
);
Ok(format!("variable {} updated (npath: {:?})", path, npath))
}

View File

@@ -0,0 +1,112 @@
use std::time::Duration;
use serde::Serialize;
use tokio::{select, sync::mpsc, time::interval};
use crate::db::DB;
lazy_static::lazy_static! {
// TODO: these aren't synced, they should be moved into the queue abstraction once/if that happens.
static ref WEBHOOK_REQUEST_COUNT: prometheus::Histogram = prometheus::register_histogram!(
"webhook_request",
"Histogram of webhook requests made"
)
.unwrap();
}
#[derive(Serialize)]
#[serde(tag = "type")]
pub enum WebhookMessage {
// See https://serde.rs/enum-representations.html#internally-tagged for how this looks in JSON
CreateApp { workspace: String, path: String },
DeleteApp { workspace: String, path: String },
UpdateApp { workspace: String, old_path: String, new_path: String },
CreateFlow { workspace: String, path: String },
UpdateFlow { workspace: String, old_path: String, new_path: String },
ArchiveFlow { workspace: String, path: String },
CreateFolder { workspace: String, name: String },
UpdateFolder { workspace: String, name: String },
DeleteFolder { workspace: String, name: String },
DeleteResource { workspace: String, path: String },
CreateResource { workspace: String, path: String },
UpdateResource { workspace: String, old_path: String, new_path: String },
CreateResourceType { name: String },
DeleteResourceType { name: String },
UpdateResourceType { name: String },
CreateScript { workspace: String, path: String, hash: String },
UpdateScript { workspace: String, path: String, hash: String },
DeleteScript { workspace: String, hash: String },
CreateVariable { workspace: String, path: String },
UpdateVariable { workspace: String, old_path: String, new_path: String },
DeleteVariable { workspace: String, path: String },
}
#[derive(Clone)]
pub struct WebhookShared {
pub channel: mpsc::UnboundedSender<(String, WebhookMessage)>,
}
impl WebhookShared {
pub fn new(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, db: DB) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<(String, WebhookMessage)>();
let _process = tokio::spawn(async move {
let client = reqwest::Client::builder()
// TODO: investigate pool timeouts and such if TCP load is high
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let cache = retainer::Cache::new();
let mut cache_purge_interval = interval(Duration::from_secs(30));
loop {
select! {
biased;
_ = shutdown_rx.recv() => break,
r = rx.recv() => match r {
Some((workspace_id, message)) => {
let url_guard = match cache.get(&workspace_id).await {
Some(guard) => {
guard
},
None => {
let Ok(webook_opt) =
sqlx::query_scalar!(
"SELECT webhook FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_one(
&db,
)
.await else {
tracing::error!("Webhook Message to send - but cannot get workspace settings! Workspace: {workspace_id}");
continue;
};
cache.insert(workspace_id.clone(), webook_opt, Duration::from_secs(30)).await;
cache.get(&workspace_id).await.unwrap()
}
};
let webook_opt = url_guard.value();
if let Some(url) = webook_opt {
let timer = WEBHOOK_REQUEST_COUNT.start_timer();
let _ = client.post(url).json(&message).send().await;
timer.stop_and_record();
drop(url_guard);
}
},
None => break,
},
_ = futures::future::poll_fn(|cx| cache_purge_interval.poll_tick(cx)) => {
tracing::trace!("Purging Webhook Cache");
cache.purge(10, 0.50).await;
},
}
}
});
Self { channel: tx }
}
pub fn send_message(&self, workspace_id: String, message: WebhookMessage) {
let _ = self.channel.send((workspace_id.clone(), message));
}
}

View File

@@ -6,24 +6,28 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::{str::FromStr, sync::Arc};
use crate::{
db::{UserDB, DB},
folders::Folder,
resources::{Resource, ResourceType},
users::{Authed, WorkspaceInvite},
utils::require_super_admin,
BaseUrl,
};
use axum::{
body::StreamBody,
extract::{Extension, Path, Query},
headers,
response::IntoResponse,
response::{IntoResponse, Redirect},
routing::{delete, get, post},
Json, Router,
};
use stripe::CustomerId;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{Error, JsonResult, Result},
error::{to_anyhow, Error, JsonResult, Result},
flows::Flow,
scripts::{Schema, Script, ScriptLang},
utils::{paginate, rd_string, require_admin, Pagination},
@@ -41,16 +45,19 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/list_pending_invites", get(list_pending_invites))
.route("/update", post(edit_workspace))
.route("/delete", delete(delete_workspace))
.route("/archive", post(archive_workspace))
.route("/invite_user", post(invite_user))
.route("/add_user", post(add_user))
.route("/delete_invite", post(delete_invite))
.route("/get_settings", get(get_settings))
.route("/edit_slack_command", post(edit_slack_command))
.route("/edit_webhook", post(edit_webhook))
.route("/edit_auto_invite", post(edit_auto_invite))
.route("/tarball", get(tarball_workspace))
.route("/premium_info", get(premium_info))
.route("/checkout", get(stripe_checkout))
.route("/billing_portal", get(stripe_portal))
}
pub fn global_service() -> Router {
Router::new()
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
@@ -60,6 +67,8 @@ pub fn global_service() -> Router {
.route("/exists", post(exists_workspace))
.route("/exists_username", post(exists_username))
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
.route("/unarchive/:workspace", post(unarchive_workspace))
.route("/delete/:workspace", delete(delete_workspace))
}
#[derive(FromRow, Serialize)]
@@ -80,6 +89,9 @@ pub struct WorkspaceSettings {
pub slack_email: String,
pub auto_invite_domain: Option<String>,
pub auto_invite_operator: Option<bool>,
pub customer_id: Option<String>,
pub plan: Option<String>,
pub webhook: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -107,6 +119,11 @@ struct EditAutoInvite {
operator: Option<bool>,
}
#[derive(Deserialize)]
struct EditWebhook {
webhook: Option<String>,
}
#[derive(Deserialize)]
struct CreateWorkspace {
id: String,
@@ -151,6 +168,14 @@ pub struct NewWorkspaceInvite {
pub operator: bool,
}
#[derive(Deserialize)]
pub struct NewWorkspaceUser {
pub email: String,
pub username: String,
pub is_admin: bool,
pub operator: bool,
}
async fn list_pending_invites(
authed: Authed,
Extension(user_db): Extension<UserDB>,
@@ -191,6 +216,148 @@ async fn premium_info(
Ok(Json(row))
}
#[derive(Deserialize)]
struct PlanQuery {
plan: String,
}
async fn stripe_checkout(
authed: Authed,
Path(w_id): Path<String>,
Query(plan): Query<PlanQuery>,
Extension(base_url): Extension<Arc<BaseUrl>>,
) -> Result<Redirect> {
// #[cfg(feature = "enterprise")]
{
require_admin(authed.is_admin, &authed.username)?;
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url.0);
let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url.0);
let checkout_session = {
let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd);
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.line_items = match plan.plan.as_str() {
"team" => Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: Some(1),
price: Some("price_1MUlrWGU3NdFi9eLE9GBZhoY".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlreGU3NdFi9eLi6sOyvVa".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlrlGU3NdFi9eLFLggSXZV".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlr3GU3NdFi9eLbZYFjR9p".to_string()),
..Default::default()
},
]),
// "enterprise" => Some(vec![
// stripe::CreateCheckoutSessionLineItems {
// quantity: None,
// price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()),
// ..Default::default()
// },
// stripe::CreateCheckoutSessionLineItems {
// quantity: None,
// price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
// ..Default::default()
// },
// ]),
_ => Err(Error::BadRequest("invalid plan".to_string()))?,
};
params.customer_email = Some(&authed.email);
params.client_reference_id = Some(&w_id);
stripe::CheckoutSession::create(&client, params)
.await
.unwrap()
};
let uri = checkout_session
.url
.ok_or_else(|| Error::InternalErr(format!("stripe checkout redirect issue")))?;
Ok(Redirect::to(&uri))
}
}
async fn stripe_portal(
authed: Authed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Extension(base_url): Extension<Arc<BaseUrl>>,
) -> Result<Redirect> {
require_admin(authed.is_admin, &authed.username)?;
let customer_id = sqlx::query_scalar!(
"SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_one(&db)
.await?
.ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
let portal_session = {
let customer_id = CustomerId::from_str(&customer_id).unwrap();
let mut params = stripe::CreateBillingPortalSession::new(customer_id);
params.return_url = Some(&success_rd);
stripe::BillingPortalSession::create(&client, params)
.await
.map_err(to_anyhow)?
};
Ok(Redirect::to(&portal_session.url))
}
// async fn stripe_usage(
// authed: Authed,
// Path(w_id): Path<String>,
// Extension(db): Extension<DB>,
// Extension(base_url): Extension<Arc<BaseUrl>>,
// ) -> Result<Redirect> {
// require_admin(authed.is_admin, &authed.username)?;
// let customer_id = sqlx::query_scalar!(
// "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
// w_id
// )
// .fetch_one(&db)
// .await?
// .ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
// let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
// let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
// let portal_session = {
// let customer_id = CustomerId::from_str(&customer_id).unwrap();
// let subscriptions = stripe::Subscription::list(
// &client,
// stripe::ListSubscriptions { customer: Some(customer_id), ..Default::default() },
// )
// .await
// .map_err(to_anyhow)?
// .data[0];
// let getUsage =
// stripe::SubscriptionItem::list(
// &client,
// stripe::ListSubscriptionItems {
// subscription: subscription.id,
// ..Default::default()
// },
// )
// .await
// .map_err(to_anyhow)
// };
// let mut params = stripe::ListSubscriptionItems::new(customer_id);
// params.return_url = Some(&success_rd);
// stripe::BillingPortalSession::create(&client, params)
// .await
// .map_err(to_anyhow)?
// };
// }
async fn exists_workspace(
authed: Authed,
Extension(user_db): Extension<UserDB>,
@@ -353,7 +520,52 @@ async fn edit_auto_invite(
.await?;
tx.commit().await?;
Ok(format!("Edit command script {}", &w_id))
Ok(format!(
"Edit auto-invite for workspace {} to {}",
&w_id, domain
))
}
async fn edit_webhook(
authed: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, .. }: Authed,
Json(ew): Json<EditWebhook>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
if let Some(webhook) = &ew.webhook {
sqlx::query!(
"UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2",
webhook,
&w_id
)
.execute(&mut tx)
.await?;
} else {
sqlx::query!(
"UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1",
&w_id,
)
.execute(&mut tx)
.await?;
}
audit_log(
&mut tx,
&authed.username,
"workspaces.edit_webhook",
ActionKind::Update,
&w_id,
Some(&authed.email),
Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()),
)
.await?;
tx.commit().await?;
Ok(format!("Edit webhook for workspace {}", &w_id))
}
async fn list_workspaces_as_super_admin(
@@ -538,10 +750,62 @@ async fn edit_workspace(
Ok(format!("Updated workspace {}", &w_id))
}
async fn delete_workspace(
async fn archive_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, email, .. }: Authed,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&username,
"workspaces.archive",
ActionKind::Update,
&w_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Archived workspace {}", &w_id))
}
async fn unarchive_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { is_admin, username, email, .. }: Authed,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id)
.execute(&mut tx)
.await?;
audit_log(
&mut tx,
&username,
"workspaces.unarchive",
ActionKind::Update,
&w_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Unarchived workspace {}", &w_id))
}
async fn delete_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Authed { username, email, .. }: Authed,
) -> Result<String> {
let w_id = match w_id.as_str() {
"starter" => Err(Error::BadRequest(
@@ -552,9 +816,68 @@ async fn delete_workspace(
)),
_ => Ok(w_id),
}?;
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id)
require_super_admin(&mut tx, &email).await?;
sqlx::query!("DELETE FROM script WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM app WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM variable WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM resource WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM schedule WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM completed_job WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM usr WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!(
"DELETE FROM workspace_invite WHERE workspace_id = $1",
&w_id
)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM usr_to_group WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM folder WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM workspace_key WHERE workspace_id = $1", &w_id)
.execute(&mut tx)
.await?;
sqlx::query!(
"DELETE FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.execute(&mut tx)
.await?;
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
.execute(&mut tx)
.await?;
@@ -562,7 +885,7 @@ async fn delete_workspace(
&mut tx,
&username,
"workspaces.delete",
ActionKind::Update,
ActionKind::Delete,
&w_id,
Some(&email),
None,
@@ -629,6 +952,37 @@ async fn invite_user(
))
}
async fn add_user(
Authed { username, is_admin, .. }: Authed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(nu): Json<NewWorkspaceUser>,
) -> Result<(StatusCode, String)> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin, operator)
VALUES ($1, $2, $3, $4, $5)",
&w_id,
nu.email,
nu.username,
nu.is_admin,
nu.operator
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok((
StatusCode::CREATED,
format!("user with email {} added", nu.email),
))
}
async fn delete_invite(
Authed { username, is_admin, .. }: Authed,
Extension(db): Extension<DB>,

View File

@@ -10,7 +10,7 @@
use axum::{
body::{self, BoxBody},
response::IntoResponse,
Json,
response::Json,
};
#[cfg(feature = "sqlx")]
@@ -58,6 +58,8 @@ pub enum Error {
ExitStatus(i32),
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
#[error("Error: {0:#?}")]
JsonErr(serde_json::Value),
}
impl Error {

View File

@@ -11,7 +11,9 @@ use std::{collections::HashMap, time::Duration};
use serde::{self, Deserialize, Serialize};
use crate::{
more_serde::{default_false, default_id, default_true, is_default},
more_serde::{
default_empty_string, default_false, default_id, default_null, default_true, is_default,
},
scripts::{Schema, ScriptHash, ScriptLang},
};
@@ -172,8 +174,14 @@ impl FlowModule {
rename_all(serialize = "lowercase", deserialize = "lowercase")
)]
pub enum InputTransform {
Static { value: serde_json::Value },
Javascript { expr: String },
Static {
#[serde(default = "default_null")]
value: serde_json::Value,
},
Javascript {
#[serde(default = "default_empty_string")]
expr: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]

View File

@@ -18,6 +18,14 @@ pub fn default_false() -> bool {
false
}
pub fn default_null() -> serde_json::Value {
serde_json::Value::Null
}
pub fn default_empty_string() -> String {
String::new()
}
pub fn default_id() -> String {
rd_string(6)
}

View File

@@ -42,7 +42,11 @@ pub fn initialize_tracing() {
match json_fmt {
true => ts_base
.with(json_layer().with_filter(filter_fn(filter_metadata)))
.with(
json_layer()
.flatten_event(true)
.with_filter(filter_fn(filter_metadata)),
)
.init(),
false => ts_base
.with(

View File

@@ -6,7 +6,10 @@
* LICENSE-AGPL for a copy of the license.
*/
pub fn owner_to_token_owner(user: &str, is_group: bool) -> String {
let prefix = if is_group { 'g' } else { 'u' };
format!("{}/{}", prefix, user)
pub fn username_to_permissioned_as(user: &str) -> String {
if user.contains('@') {
user.to_string()
} else {
format!("u/{}", user)
}
}

View File

@@ -11,8 +11,8 @@ use serde::Deserialize;
use crate::error::{Error, Result};
pub const MAX_PER_PAGE: usize = 1000;
pub const DEFAULT_PER_PAGE: usize = 100;
pub const MAX_PER_PAGE: usize = 10000;
pub const DEFAULT_PER_PAGE: usize = 1000;
#[derive(Deserialize)]
pub struct Pagination {
@@ -72,10 +72,6 @@ pub fn not_found_if_none<T, U: AsRef<str>>(opt: Option<T>, kind: &str, name: U)
}
}
pub fn get_owner_from_path(path: &str) -> String {
path.split('/').take(2).collect::<Vec<_>>().join("/")
}
#[cfg(feature = "reqwest")]
pub async fn list_elems_from_hub(
http_client: reqwest::Client,

View File

@@ -8,6 +8,7 @@
use std::{collections::HashMap, str::FromStr};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres, Transaction};
use tracing::{instrument, Instrument};
@@ -44,6 +45,7 @@ lazy_static::lazy_static! {
}
const MAX_FREE_EXECS: i32 = 1000;
const MAX_FREE_CONCURRENT_RUNS: i32 = 15;
pub async fn cancel_job<'c>(
username: &str,
@@ -80,7 +82,32 @@ pub async fn cancel_job<'c>(
Ok((tx, job_option))
}
pub async fn pull(db: &Pool<Postgres>) -> windmill_common::error::Result<Option<QueuedJob>> {
pub async fn pull(
db: &Pool<Postgres>,
whitelist_workspaces: Option<Vec<String>>,
blacklist_workspaces: Option<Vec<String>>,
) -> windmill_common::error::Result<Option<QueuedJob>> {
let mut workspaces_filter = String::new();
if let Some(whitelist) = whitelist_workspaces {
workspaces_filter.push_str(&format!(
" AND workspace_id IN ({})",
whitelist
.into_iter()
.map(|x| format!("'{x}'"))
.collect::<Vec<String>>()
.join(",")
));
}
if let Some(blacklist) = blacklist_workspaces {
workspaces_filter.push_str(&format!(
" AND workspace_id NOT IN ({})",
blacklist
.into_iter()
.map(|x| format!("'{x}'"))
.collect::<Vec<String>>()
.join(",")
));
}
/* Jobs can be started if they:
* - haven't been started before,
* running = false
@@ -88,7 +115,7 @@ pub async fn pull(db: &Pool<Postgres>) -> windmill_common::error::Result<Option<
* suspend_until is non-null
* and suspend = 0 when the resume messages are received
* or suspend_until <= now() if it has timed out */
let job: Option<QueuedJob> = sqlx::query_as::<_, QueuedJob>(
let job: Option<QueuedJob> = sqlx::query_as::<_, QueuedJob>(&format!(
"UPDATE queue
SET running = true
, started_at = coalesce(started_at, now())
@@ -97,17 +124,17 @@ pub async fn pull(db: &Pool<Postgres>) -> windmill_common::error::Result<Option<
WHERE id = (
SELECT id
FROM queue
WHERE ( running = false
WHERE ((running = false
AND scheduled_for <= now())
OR (suspend_until IS NOT NULL
AND ( suspend <= 0
OR suspend_until <= now()))
OR suspend_until <= now()))) {workspaces_filter}
ORDER BY scheduled_for
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *",
)
RETURNING *"
))
.fetch_optional(db)
.await?;
@@ -172,7 +199,6 @@ pub async fn get_result_by_id(
"Flow result by id",
format!("{}, {}", flow_id, node_id),
)?;
println!("result_id: {:#?}, {node_id}", result_id);
let value = match result_id {
JobResult::ListJob(x) => {
@@ -318,10 +344,38 @@ pub async fn push<'c>(
.fetch_optional(&mut tx)
.await?
.unwrap_or(false);
if !is_super_admin && usage > MAX_FREE_EXECS {
return Err(error::Error::BadRequest(format!(
if !is_super_admin {
if usage > MAX_FREE_EXECS {
return Err(error::Error::BadRequest(format!(
"User {email} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
let in_queue =
sqlx::query_scalar!("SELECT COUNT(id) FROM queue WHERE email = $1", email)
.fetch_one(&mut tx)
.await?
.unwrap_or(0);
if in_queue > MAX_FREE_EXECS.into() {
return Err(error::Error::BadRequest(format!(
"User {email} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
)));
}
let concurrent_runs = sqlx::query_scalar!(
"SELECT COUNT(id) FROM queue WHERE running = true AND email = $1",
email
)
.fetch_one(&mut tx)
.await?
.unwrap_or(0);
if concurrent_runs > MAX_FREE_CONCURRENT_RUNS.into() {
return Err(error::Error::BadRequest(format!(
"User {email} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces."
)));
}
}
}
}
@@ -352,7 +406,9 @@ pub async fn push<'c>(
)
}
JobPayload::ScriptHub { path } => {
let script = get_hub_script(path.clone(), email).await?;
let script = get_hub_script(path.clone(), email)
.await
.context("error fetching hub script")?;
(
None,
Some(path),
@@ -545,13 +601,13 @@ pub async fn push<'c>(
Ok((uuid, tx))
}
pub fn canceled_job_to_result(job: &QueuedJob) -> String {
pub fn canceled_job_to_result(job: &QueuedJob) -> serde_json::Value {
let reason = job
.canceled_reason
.as_deref()
.unwrap_or_else(|| "no reason given");
let canceler = job.canceled_by.as_deref().unwrap_or_else(|| "unknown");
format!("Job canceled: {reason} by {canceler}")
serde_json::json!({"message": format!("Job canceled: {reason} by {canceler}"), "name": "Canceled", "reason": reason, "canceler": canceler})
}
pub async fn get_hub_script(path: String, email: &str) -> error::Result<HubScript> {
@@ -571,34 +627,52 @@ pub async fn get_hub_script(path: String, email: &str) -> error::Result<HubScrip
pub struct QueuedJob {
pub workspace_id: String,
pub id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_job: Option<Uuid>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_for: chrono::DateTime<chrono::Utc>,
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub script_hash: Option<ScriptHash>,
#[serde(skip_serializing_if = "Option::is_none")]
pub script_path: Option<String>,
pub args: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logs: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_lock: Option<String>,
pub canceled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub canceled_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canceled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_ping: Option<chrono::DateTime<chrono::Utc>>,
pub job_kind: JobKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_path: Option<String>,
pub permissioned_as: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_status: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_flow: Option<serde_json::Value>,
pub is_flow_step: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<ScriptLang>,
pub same_worker: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pre_run_error: Option<String>,
pub email: String,
pub visible_to_owner: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub suspend: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_peak: Option<i32>,
}
impl QueuedJob {

View File

@@ -13,7 +13,8 @@ use sqlx::{query_scalar, Postgres, Transaction};
use windmill_common::{
error::{self, Result},
schedule::Schedule,
utils::{get_owner_from_path, now_from_db, StripPath},
users::username_to_permissioned_as,
utils::{now_from_db, StripPath},
};
use crate::{push, JobPayload};
@@ -80,7 +81,7 @@ pub async fn push_scheduled_job<'c>(
args,
&schedule_to_user(&schedule.path),
&schedule.email,
get_owner_from_path(&schedule.path),
username_to_permissioned_as(&schedule.edited_by),
Some(next),
Some(schedule.path.clone()),
None,

View File

@@ -0,0 +1,55 @@
import sys
import os
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec, SourceFileLoader
class WindmillLoader(Loader):
def __init__(self, path):
self.path = path
def create_module(self, spec):
return None
def exec_module(self, module):
module.__path__ = self.path
return None
class WindmillFinder(MetaPathFinder):
@classmethod
def find_spec(cls, name, path, target=None):
splitted = name.split(".")
if splitted[0] != "f" and splitted[0] != "u":
return None
l = len(splitted)
if l <= 2:
return ModuleSpec(name, WindmillLoader(name))
elif l == 3:
script_path = "/".join(splitted)
import requests
url = f"{os.environ.get('BASE_INTERNAL_URL')}/api/w/{os.environ.get('WM_WORKSPACE')}/scripts/raw/p/{script_path}"
r = requests.get(
url, headers={"Authorization": f"Bearer {os.environ.get('WM_TOKEN')}"}
)
if r.status_code == 200:
folder = os.getcwd() + "/tmp/" + "/".join(splitted[:-1])
fullpath = folder + "/" + splitted[-1] + ".py"
os.makedirs(folder, exist_ok=True)
with open(fullpath, "w+") as f:
f.write(r.text)
return ModuleSpec(name, SourceFileLoader(name, fullpath))
else:
print(r.text, r.status_code)
raise ImportError(f"Script {script_path} not found")
else:
raise ImportError(
"Import can only be done at the top level of a folder or user space"
)
sys.meta_path.append(WindmillFinder)

View File

@@ -3,12 +3,11 @@ name: "bash run script"
mode: ONCE
hostname: "bash"
log_level: ERROR
time_limit: 300
rlimit_as: 2048
rlimit_as: 4096
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
rlimit_fsize: 1000
rlimit_nofile: 10000
cwd: "/tmp"
@@ -55,7 +54,7 @@ mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size=500000000"
options: "size=800000000"
}
mount {
@@ -87,4 +86,4 @@ iface_no_lo: true
{SHARED_MOUNT}
envar: "HOME=/tmp"

View File

@@ -3,12 +3,11 @@ name: "deno run script"
mode: ONCE
hostname: "deno"
log_level: ERROR
time_limit: 300
rlimit_as: 16000
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
rlimit_fsize: 1000
rlimit_nofile: 10000
cwd: "/tmp"
@@ -73,6 +72,12 @@ mount {
is_bind: true
}
mount {
src: "{JOB_DIR}/import_map.json"
dst: "/tmp/import_map.json"
is_bind: true
}
mount {
src: "{JOB_DIR}/lock.json"
dst: "/tmp/lock.json"

View File

@@ -3,12 +3,11 @@ name: "go run script"
mode: ONCE
hostname: "go"
log_level: ERROR
time_limit: 300
rlimit_as: 2048
rlimit_as: 4096
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
rlimit_fsize: 1000
rlimit_nofile: 10000
cwd: "/tmp/go"

View File

@@ -3,12 +3,11 @@ name: "python run script"
mode: ONCE
hostname: "python"
log_level: ERROR
time_limit: 300
rlimit_as: 2048
rlimit_as: 4096
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
rlimit_fsize: 1000
rlimit_nofile: 10000
cwd: "/tmp"
@@ -72,6 +71,13 @@ mount {
is_bind: true
}
mount {
src: "{JOB_DIR}/loader.py"
dst: "/tmp/loader.py"
is_bind: true
mandatory: false
}
mount {
src: "{JOB_DIR}/main.py"
dst: "/tmp/main.py"

View File

@@ -6,7 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
use serde_json::{Map, Value};
use sqlx::{Pool, Postgres, Transaction};
use tracing::instrument;
use uuid::Uuid;
@@ -14,36 +13,17 @@ use windmill_common::{error::Error, flow_status::FlowStatusModule, schedule::Sch
use windmill_queue::{delete_job, schedule::get_schedule_opt, JobKind, QueuedJob};
#[instrument(level = "trace", skip_all)]
pub async fn add_completed_job_error<E: ToString + std::fmt::Debug>(
pub async fn add_completed_job_error(
db: &Pool<Postgres>,
queued_job: &QueuedJob,
logs: String,
e: E,
e: serde_json::Value,
metrics: Option<crate::worker::Metrics>,
) -> Result<(Uuid, serde_json::Map<String, serde_json::Value>), Error> {
) -> Result<serde_json::Value, Error> {
metrics.map(|m| m.worker_execution_failed.inc());
let mut output_map = Map::new();
error_to_result(&mut output_map, &e);
let a = add_completed_job(
db,
&queued_job,
false,
false,
serde_json::Value::Object(output_map.clone()),
logs,
)
.await?;
Ok((a, output_map))
}
pub fn error_to_result<E: ToString + std::fmt::Debug>(
output_map: &mut Map<String, Value>,
err: &E,
) {
output_map.insert(
"error".to_string(),
serde_json::Value::String(err.to_string()),
);
let result = serde_json::json!({ "error": e });
let _ = add_completed_job(db, &queued_job, false, false, result.clone(), logs).await?;
Ok(result)
}
fn flatten_jobs(modules: Vec<FlowStatusModule>) -> Vec<Uuid> {
@@ -97,6 +77,12 @@ pub async fn add_completed_job(
None
};
let mem_peak = sqlx::query_scalar!("SELECT mem_peak FROM queue WHERE id = $1", &queued_job.id)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten();
let mut tx = db.begin().await?;
let job_id = queued_job.id.clone();
sqlx::query!(
@@ -129,9 +115,10 @@ pub async fn add_completed_job(
, language
, email
, visible_to_owner
, mem_peak
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, EXTRACT(milliseconds FROM (now() - $6))), $7, $8, $9,\
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $28)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE($6, now()))))*1000), $7, $8, $9,\
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $28, $29)
ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)",
queued_job.workspace_id,
queued_job.id,
@@ -160,7 +147,8 @@ pub async fn add_completed_job(
queued_job.language: ScriptLang,
duration: Option<i64>,
queued_job.email,
queued_job.visible_to_owner
queued_job.visible_to_owner,
mem_peak
)
.execute(&mut tx)
.await

View File

@@ -9,8 +9,13 @@
use const_format::concatcp;
use git_version::git_version;
use itertools::Itertools;
use lazy_static::lazy_static;
use regex::Regex;
use sqlx::{Pool, Postgres, Transaction};
use std::{borrow::Borrow, collections::HashMap, io, panic, process::Stdio, time::Duration};
use std::{
borrow::Borrow, collections::HashMap, io, os::unix::process::ExitStatusExt, panic,
process::Stdio, time::Duration,
};
use tracing::{trace_span, Instrument};
use uuid::Uuid;
@@ -21,17 +26,16 @@ use windmill_common::{
utils::rd_string,
variables,
};
use windmill_queue::{canceled_job_to_result, get_queued_job, pull, JobKind, QueuedJob};
use windmill_queue::{canceled_job_to_result, get_queued_job, pull, JobKind, QueuedJob, CLOUD_HOSTED};
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use tokio::{
fs::{metadata, symlink, DirBuilder, File},
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{
mpsc::{self, Sender},
oneshot, watch,
mpsc::{self, Sender}, watch,
},
time::{interval, sleep, Instant, MissedTickBehavior},
};
@@ -44,7 +48,7 @@ use futures::{
use async_recursion::async_recursion;
use crate::{
jobs::{add_completed_job, add_completed_job_error, error_to_result},
jobs::{add_completed_job, add_completed_job_error},
worker_flow::{
handle_flow, update_flow_status_after_job_completion, update_flow_status_in_progress,
},
@@ -128,7 +132,7 @@ async fn copy_cache_to_bucket_as_tar(bucket: &str) {
.current_dir(ROOT_CACHE_DIR)
.arg("-c")
.arg("-f")
.arg(format!("{ROOT_CACHE_DIR}/{TAR_CACHE_FILENAME}"))
.arg(format!("{ROOT_CACHE_DIR}{TAR_CACHE_FILENAME}"))
.args(&["pip", "go", "deno"])
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -176,7 +180,7 @@ async fn copy_cache_from_bucket_as_tar(bucket: &str) -> bool {
match Command::new("rclone")
.arg("copyto")
.arg(format!(":s3,env_auth=true:{bucket}/{TAR_CACHE_FILENAME}"))
.arg(format!("{ROOT_CACHE_DIR}/{TAR_CACHE_FILENAME}"))
.arg(format!("{ROOT_CACHE_DIR}{TAR_CACHE_FILENAME}"))
.arg("--size-only")
.arg("--fast-list")
.stdin(Stdio::null())
@@ -229,29 +233,27 @@ pub async fn create_token_for_owner<'c>(
owner: &str,
label: &str,
expires_in: i32,
username: &str,
email: &str,
) -> error::Result<(Transaction<'c, Postgres>, String)> {
// TODO: Bad implementation. We should not have access to this DB here.
let token: String = rd_string(30);
let is_super_admin = username.contains('@')
&& sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
owner.split_once('/').map(|x| x.1).unwrap_or("")
)
.fetch_optional(&mut tx)
.await?
.unwrap_or(false);
let is_super_admin = owner.contains('@')
&& sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", owner)
.fetch_optional(&mut tx)
.await?
.unwrap_or(false);
sqlx::query_scalar!(
"INSERT INTO token
(workspace_id, token, owner, label, expiration, super_admin)
VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6)",
(workspace_id, token, owner, label, expiration, super_admin, email)
VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6, $7)",
&w_id,
token,
owner,
label,
expires_in.to_string(),
is_super_admin
is_super_admin,
email
)
.execute(&mut tx)
.await?;
@@ -271,9 +273,10 @@ const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
const NSJAIL_CONFIG_RUN_GO_CONTENT: &str = include_str!("../nsjail/run.go.config.proto");
const NSJAIL_CONFIG_RUN_BASH_CONTENT: &str = include_str!("../nsjail/run.bash.config.proto");
const NSJAIL_CONFIG_RUN_DENO_CONTENT: &str = include_str!("../nsjail/run.deno.config.proto");
const MAX_LOG_SIZE: u32 = 200000;
const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
const GO_REQ_SPLITTER: &str = "//go.sum";
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
@@ -313,6 +316,11 @@ lazy_static::lazy_static! {
);
}
//only matter if CLOUD_HOSTED
const MAX_RESULT_SIZE: usize = 1024 * 1024 * 2; // 2MB
#[tracing::instrument(level = "trace")]
pub async fn run_worker(
db: &Pool<Postgres>,
@@ -400,6 +408,13 @@ pub async fn run_worker(
)
.expect("register prometheus metric");
let worker_busy: prometheus::IntGauge = prometheus::register_int_gauge!(prometheus::Opts::new(
"worker_busy",
"Is the worker busy executing a job?",
)
.const_label("name", &worker_name))
.unwrap();
let mut jobs_executed = 0;
let deno_path = std::env::var("DENO_PATH").unwrap_or_else(|_| "/usr/bin/deno".to_string());
@@ -412,6 +427,32 @@ pub async fn run_worker(
let pip_index_url = std::env::var("PIP_INDEX_URL").ok();
let pip_extra_index_url = std::env::var("PIP_EXTRA_INDEX_URL").ok();
let pip_trusted_host = std::env::var("PIP_TRUSTED_HOST").ok();
let deno_auth_tokens = std::env::var("DENO_AUTH_TOKENS")
.ok()
.map(|x| format!(";{x}"))
.unwrap_or_else(|| String::new());
let deno_flags = std::env::var("DENO_FLAGS")
.ok()
.map(|x| x.split(' ').map(|x| x.to_string()).collect());
let pip_local_dependencies = std::env::var("PIP_LOCAL_DEPENDENCIES")
.ok()
.map(|x| x.split(',').map(|x| x.to_string()).collect());
let whitelist_workspaces = std::env::var("WHITELIST_WORKSPACES")
.ok()
.map(|x| x.split(',').map(|x| x.to_string()).collect());
let blacklist_workspaces = std::env::var("BLACKLIST_WORKSPACES")
.ok()
.map(|x| x.split(',').map(|x| x.to_string()).collect());
let pip_local_dependencies = if pip_local_dependencies == Some(vec!["".to_string()]) {
None
} else {
pip_local_dependencies
};
#[cfg(feature = "enterprise")]
let tar_cache_rate = std::env::var("TAR_CACHE_RATE")
@@ -419,6 +460,10 @@ pub async fn run_worker(
.and_then(|x| x.parse::<i32>().ok())
.unwrap_or(100);
let additional_python_paths = std::env::var("ADDITIONAL_PYTHON_PATHS")
.ok()
.map(|x| x.split(':').map(|x| x.to_string()).collect());
let envs = Envs {
deno_path,
go_path,
@@ -429,6 +474,10 @@ pub async fn run_worker(
pip_index_url,
pip_extra_index_url,
pip_trusted_host,
deno_flags,
deno_auth_tokens,
pip_local_dependencies,
additional_python_paths,
};
WORKER_STARTED.inc();
@@ -448,11 +497,14 @@ pub async fn run_worker(
let (same_worker_tx, mut same_worker_rx) = mpsc::channel::<Uuid>(5);
loop {
worker_busy.set(0);
uptime_metric.inc_by(
((Instant::now() - start_time).as_millis() - uptime_metric.get() as u128)
.try_into()
.unwrap(),
);
let do_break = async {
if last_ping.elapsed().as_secs() > NUM_SECS_ENV_CHECK {
sqlx::query!(
@@ -493,7 +545,9 @@ pub async fn run_worker(
.await
.map_err(|_| Error::InternalErr("Impossible to fetch same_worker job".to_string())))
},
(job, timer) = {let timer = worker_pull_duration.start_timer(); pull(&db).map(|x| (x, timer)) } => {
(job, timer) = {
let timer = worker_pull_duration.start_timer();
pull(&db, whitelist_workspaces.clone(), blacklist_workspaces.clone()).map(|x| (x, timer)) } => {
drop(timer);
(false, job)
},
@@ -503,6 +557,7 @@ pub async fn run_worker(
return true;
}
worker_busy.set(1);
match next_job {
Ok(Some(job)) => {
let label_values = [
@@ -563,11 +618,11 @@ pub async fn run_worker(
&job.permissioned_as,
"ephemeral-script",
timeout * 2,
&job.created_by,
&job.email,
)
.await.expect("could not create job token");
tx.commit().await.expect("could not commit job token");
let job_client = windmill_api_client::create_client(&worker_config.base_url, token.clone());
let job_client = windmill_api_client::create_client(&worker_config.base_internal_url, token.clone());
let is_flow = job.job_kind == JobKind::Flow || job.job_kind == JobKind::FlowPreview || job.job_kind == JobKind::FlowDependencies;
if let Some(err) = handle_queued_job(
@@ -643,26 +698,29 @@ async fn handle_job_error(
keep_job_dir: bool,
base_internal_url: &str,
) {
add_completed_job_error(
db,
&job,
format!("Unexpected error during job execution:\n{err}"),
&err,
metrics.clone(),
)
.await
.map(|(_, m)| m)
.unwrap_or_else(|_| Map::new());
let err = match err {
Error::JsonErr(err) => err,
_ => json!({"message": err.to_string(), "name": "InternalErr"}),
};
let update_job_future = || {
add_completed_job_error(
db,
&job,
format!("Unexpected error during job execution:\n{err}"),
err.clone(),
metrics.clone(),
)
};
if job.is_flow_step || job.job_kind == JobKind::FlowPreview || job.job_kind == JobKind::Flow {
let (flow, job_status_to_update) = if let Some(parent_job_id) = job.parent_job {
let _ = update_job_future().await;
(parent_job_id, job.id)
} else {
(job.id, Uuid::nil())
};
let mut output_map = serde_json::Map::new();
error_to_result(&mut output_map, &err);
let updated_flow = update_flow_status_after_job_completion(
db,
client,
@@ -670,7 +728,7 @@ async fn handle_job_error(
&job_status_to_update,
&job.workspace_id,
false,
serde_json::Value::Object(output_map),
json!({ "error": err }),
metrics.clone(),
unrecoverable,
same_worker_tx,
@@ -680,9 +738,8 @@ async fn handle_job_error(
None,
)
.await;
if let Err(err) = updated_flow {
println!("error updating flow status: {}", err);
if let Err(err) = updated_flow {
if let Some(parent_job_id) = job.parent_job {
if let Ok(mut tx) = db.begin().await {
if let Ok(Some(parent_job)) =
@@ -692,7 +749,7 @@ async fn handle_job_error(
db,
&parent_job,
format!("Unexpected error during flow job error handling:\n{err}"),
err,
json!({"message": err.to_string(), "name": "InternalErr"}),
metrics.clone(),
)
.await;
@@ -701,7 +758,10 @@ async fn handle_job_error(
}
}
}
tracing::error!(job_id = %job.id, err = err.alt(), "error handling job: {} {} {}", job.id, job.workspace_id, job.created_by);
if job.parent_job.is_none() {
let _ = update_job_future().await;
}
tracing::error!(job_id = %job.id, "error handling job: {err:#?} {} {} {}", job.id, job.workspace_id, job.created_by);
}
async fn insert_initial_ping(
@@ -731,8 +791,15 @@ struct Envs {
pip_index_url: Option<String>,
pip_extra_index_url: Option<String>,
pip_trusted_host: Option<String>,
deno_auth_tokens: String,
deno_flags: Option<Vec<String>>,
pip_local_dependencies: Option<Vec<String>>,
additional_python_paths: Option<Vec<String>>,
}
fn extract_error_value(log_lines: &str) -> serde_json::Value {
return json!({"message": log_lines.to_string().trim().to_string(), "name": "ExecutionErr"});
}
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_queued_job(
job: QueuedJob,
@@ -750,7 +817,7 @@ async fn handle_queued_job(
base_internal_url: &str,
) -> windmill_common::error::Result<()> {
if job.canceled {
return Err(Error::ExecutionErr(canceled_job_to_result(&job)))?;
return Err(Error::JsonErr(canceled_job_to_result(&job)))?;
}
if let Some(e) = job.pre_run_error {
return Err(Error::ExecutionErr(e));
@@ -853,32 +920,36 @@ async fn handle_queued_job(
}
}
Err(e) => {
let error_message = match e {
let error_value = match e {
Error::ExitStatus(_) => {
let last_10_log_lines = logs
.lines()
.skip(logs.lines().count().max(13) - 13)
.join("\n")
.to_string()
.replace("\n\n", "\n");
let res = read_result(job_dir).await.ok();
let log_lines = last_10_log_lines
.split("CODE EXECUTION ---")
.last()
.unwrap_or(&logs);
format!("Error during execution of the script:\n{}", log_lines)
if res.is_some() && res.clone().unwrap().is_object() {
res.unwrap()
} else {
let last_10_log_lines = logs
.lines()
.skip(logs.lines().count().max(13) - 13)
.join("\n")
.to_string()
.replace("\n\n", "\n");
let log_lines = last_10_log_lines
.split("CODE EXECUTION ---")
.last()
.unwrap_or(&logs);
extract_error_value(log_lines)
}
}
err @ _ => {
json!({"message": format!("error during execution of the script:\n{}", err), "name": "ExecutionErr"})
}
err @ _ => format!("error before termination: {err:#?}"),
};
let (_, output_map) = add_completed_job_error(
db,
&job,
logs,
error_message,
Some(metrics.clone()),
)
.await?;
let result =
add_completed_job_error(db, &job, logs, error_value, Some(metrics.clone()))
.await?;
if job.is_flow_step {
if let Some(parent_job) = job.parent_job {
update_flow_status_after_job_completion(
@@ -888,7 +959,7 @@ async fn handle_queued_job(
&job.id,
&job.workspace_id,
false,
serde_json::Value::Object(output_map),
result,
Some(metrics),
false,
same_worker_tx,
@@ -918,6 +989,7 @@ async fn write_file(dir: &str, path: &str, content: &str) -> error::Result<File>
#[async_recursion]
async fn transform_json_value(
name: &str,
client: &windmill_api_client::Client,
workspace: &str,
v: Value,
@@ -928,32 +1000,32 @@ async fn transform_json_value(
let v = client
.get_variable(workspace, path, Some(true))
.await
.map_err(to_anyhow)
.map_err(|_| Error::NotFound(format!("Variable {path} not found for `{name}`")))
.map(|v| v.into_inner())?
.value
.map_or_else(
|| Err(Error::NotFound(format!("Variable not found at {path}"))),
|e| Ok(e),
)?;
.unwrap_or_else(|| String::new());
Ok(Value::String(v))
}
Value::String(y) if y.starts_with("$res:") => {
let path = y.strip_prefix("$res:").unwrap();
if path.split("/").count() < 2 {
return Err(Error::InternalErr(
format!("invalid resource path: {path}",),
));
return Err(Error::InternalErr(format!(
"Argument `{name}` is an invalid resource path: {path}",
)));
}
let v = client
.get_resource_value(workspace, path)
.await
.map_err(to_anyhow)?
.map_err(|_| Error::NotFound(format!("Resource {path} not found for `{name}`")))?
.into_inner();
transform_json_value(client, workspace, v).await
transform_json_value(name, client, workspace, v).await
}
Value::Object(mut m) => {
for (a, b) in m.clone().into_iter() {
m.insert(a, transform_json_value(client, workspace, b).await?);
m.insert(
a.clone(),
transform_json_value(&a, client, workspace, b).await?,
);
}
Ok(Value::Object(m))
}
@@ -1274,7 +1346,7 @@ func Run(req Req) (interface{{}}, error){{
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
handle_child(&job.id, db, logs, timeout, build_go).await?;
handle_child(&job.id, db, logs, timeout, build_go, false).await?;
Command::new(nsjail_path)
.current_dir(job_dir)
@@ -1300,14 +1372,14 @@ func Run(req Req) (interface{{}}, error){{
.stderr(Stdio::piped())
.spawn()?
};
handle_child(&job.id, db, logs, timeout, child).await?;
handle_child(&job.id, db, logs, timeout, child, !disable_nsjail).await?;
read_result(job_dir).await
}
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_bash_job(
WorkerConfig { base_internal_url, disable_nuser, disable_nsjail, base_url, .. }: &WorkerConfig,
Envs { nsjail_path, path_env, home_env, .. }: &Envs,
Envs { nsjail_path, path_env, home_env, .. }: &Envs,
logs: &mut String,
job: &QueuedJob,
db: &sqlx::Pool<sqlx::Postgres>,
@@ -1379,7 +1451,7 @@ async fn handle_bash_job(
.stderr(Stdio::piped())
.spawn()?
};
handle_child(&job.id, db, logs, timeout, child).await?;
handle_child(&job.id, db, logs, timeout, child, !disable_nsjail).await?;
//for now bash jobs have an empty result object
Ok(serde_json::json!(logs
.lines()
@@ -1399,7 +1471,7 @@ fn capitalize(s: &str) -> String {
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_deno_job(
WorkerConfig { base_internal_url, base_url, disable_nuser, disable_nsjail, .. }: &WorkerConfig,
Envs { nsjail_path, deno_path, path_env, .. }: &Envs,
Envs { nsjail_path, deno_path, path_env, deno_auth_tokens, deno_flags, .. }: &Envs,
logs: &mut String,
job: &QueuedJob,
db: &sqlx::Pool<sqlx::Postgres>,
@@ -1442,16 +1514,30 @@ async function run() {{
await Deno.writeTextFile("result.json", res_json);
Deno.exit(0);
}}
run();
run().catch(async (e) => {{
await Deno.writeTextFile("result.json", JSON.stringify({{ message: e.message, name: e.name, stack: e.stack }}));
Deno.exit(1);
}});
"#,
);
write_file(job_dir, "main.ts", &wrapper_content).await?;
let w_id = job.workspace_id.clone();
let import_map = format!(
r#"{{
"imports": {{
"/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/",
"./": "./"
}}
}}"#
);
write_file(job_dir, "import_map.json", &import_map).await?;
let mut reserved_variables = get_reserved_variables(job, &token, &base_url, db).await?;
reserved_variables.insert("RUST_LOG".to_string(), "info".to_string());
let hostname_base = base_url.split("://").last().unwrap_or("localhost");
let hostname_internal = base_internal_url.split("://").last().unwrap_or("localhost");
let deno_auth_tokens = format!("{token}@{hostname_base};{token}@{hostname_internal}");
let deno_auth_tokens =
format!("{token}@{hostname_base};{token}@{hostname_internal}{deno_auth_tokens}",);
let child = async {
Ok(if !disable_nsjail {
let _ = write_file(
@@ -1473,9 +1559,16 @@ run();
if lockfile.is_some() {
args.push("--lock=/tmp/lock.json");
}
args.push("--import-map");
args.push("/tmp/import_map.json");
args.push("--unstable");
args.push("--v8-flags=--max-heap-size=2048");
args.push("-A");
if let Some(deno_flags) = deno_flags {
for flag in deno_flags {
args.push(flag);
}
} else {
args.push("-A");
}
args.push("/tmp/main.ts");
Command::new(nsjail_path)
@@ -1492,13 +1585,18 @@ run();
} else {
let mut args = Vec::new();
let script_path = format!("{job_dir}/main.ts");
let import_map_path = format!("{job_dir}/import_map.json");
args.push("run");
if lockfile.is_some() {
args.push("--lock=/tmp/lock.json");
}
args.push("--import-map");
args.push(&import_map_path);
args.push("--unstable");
args.push("--v8-flags=--max-heap-size=2048");
args.push("-A");
if let Some(deno_flags) = deno_flags {
for flag in deno_flags {
args.push(flag);
}
} else {
args.push("-A");
}
args.push(&script_path);
Command::new(deno_path)
.current_dir(job_dir)
@@ -1517,7 +1615,7 @@ run();
}
.instrument(trace_span!("create_deno_jail"))
.await?;
handle_child(&job.id, db, logs, timeout, child).await?;
handle_child(&job.id, db, logs, timeout, child, !disable_nsjail).await?;
read_result(job_dir).await
}
@@ -1528,7 +1626,7 @@ async fn create_args_and_out_file(
job_dir: &str,
) -> Result<(), Error> {
let args = if let Some(args) = &job.args {
Some(transform_json_value(client, &job.workspace_id, args.clone()).await?)
Some(transform_json_value("args", client, &job.workspace_id, args.clone()).await?)
} else {
None
};
@@ -1538,10 +1636,16 @@ async fn create_args_and_out_file(
Ok(())
}
lazy_static! {
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(u|f)\."#).unwrap();
}
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_python_job(
WorkerConfig { base_internal_url, base_url, disable_nuser, disable_nsjail, .. }: &WorkerConfig,
envs @ Envs { nsjail_path, python_path, path_env, .. }: &Envs,
envs @ Envs {
nsjail_path, python_path, path_env, additional_python_paths, ..
}: &Envs,
requirements_o: Option<String>,
job_dir: &str,
worker_dir: &str,
@@ -1557,7 +1661,8 @@ async fn handle_python_job(
) -> error::Result<serde_json::Value> {
create_dependencies_dir(job_dir).await;
let mut additional_python_paths: Vec<String> = vec![];
let mut additional_python_paths: Vec<String> =
additional_python_paths.to_owned().unwrap_or_else(|| vec![]);
let requirements = match requirements_o {
Some(r) => r,
@@ -1608,12 +1713,17 @@ async fn handle_python_job(
set_logs(logs, &job.id, db).await;
let relative_imports = RELATIVE_IMPORT_REGEX.is_match(&inner_content);
let _ = write_file(job_dir, "inner.py", inner_content).await?;
if relative_imports {
let _ = write_file(job_dir, "loader.py", RELATIVE_PYTHON_LOADER).await?;
}
let sig = windmill_parser_py::parse_python_signature(inner_content)?;
let transforms = sig
.args
.into_iter()
.iter()
.map(|x| match x.typ {
windmill_parser::Typ::Bytes => {
format!(
@@ -1636,24 +1746,70 @@ async fn handle_python_job(
.join("");
create_args_and_out_file(client, job, job_dir).await?;
let import_loader = if relative_imports {
"import loader"
} else {
""
};
let import_base64 = if sig
.args
.iter()
.any(|x| x.typ == windmill_parser::Typ::Bytes)
{
"import base64"
} else {
""
};
let import_datetime = if sig
.args
.iter()
.any(|x| x.typ == windmill_parser::Typ::Datetime)
{
"from datetime import datetime"
} else {
""
};
let spread = if sig.star_kwargs {
"args = kwargs".to_string()
} else {
sig.args
.into_iter()
.map(|x| format!("args[\"{}\"] = kwargs[\"{}\"]", x.name, x.name))
.join("\n")
};
let wrapper_content: String = format!(
r#"
import json
import base64
from datetime import datetime
{import_loader}
{import_base64}
{import_datetime}
import traceback
import sys
inner_script = __import__("inner")
with open("args.json") as f:
kwargs = json.load(f, strict=False)
for k, v in list(kwargs.items()):
if v == '<function call>':
del kwargs[k]
args = {{}}
{spread}
{transforms}
res = inner_script.main(**kwargs)
res_json = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
with open("result.json", 'w') as f:
f.write(res_json)
for k, v in list(args.items()):
if v == '<function call>':
del args[k]
try:
res = inner_script.main(**args)
res_json = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
with open("result.json", 'w') as f:
f.write(res_json)
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
tb = traceback.format_tb(exc_traceback)
with open("result.json", 'w') as f:
err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '')
f.write(err_json)
sys.exit(1)
"#,
);
write_file(job_dir, "main.py", &wrapper_content).await?;
@@ -1733,7 +1889,7 @@ mount {{
.spawn()?
};
handle_child(&job.id, db, logs, timeout, child).await?;
handle_child(&job.id, db, logs, timeout, child, !disable_nsjail).await?;
read_result(job_dir).await
}
@@ -1749,6 +1905,10 @@ async fn read_result(job_dir: &str) -> error::Result<serde_json::Value> {
let mut file = File::open(format!("{job_dir}/result.json")).await?;
let mut content = "".to_string();
file.read_to_string(&mut content).await?;
if *CLOUD_HOSTED && content.len() > MAX_RESULT_SIZE {
return Err(Error::ExecutionErr("Result is too large for the cloud app (limit 2MB).
If using this script as part of the flow, use the shared folder to pass heavy data between steps.".to_owned()));
}
serde_json::from_str(&content)
.map_err(|e| Error::ExecutionErr(format!("Error parsing result: {e}")))
}
@@ -1929,7 +2089,7 @@ async fn generate_deno_lock(
job_dir: &str,
db: &sqlx::Pool<sqlx::Postgres>,
timeout: i32,
Envs { deno_path, .. }: &Envs,
Envs { deno_path, .. }: &Envs,
) -> error::Result<String> {
let _ = write_file(job_dir, "main.ts", code).await?;
@@ -1947,7 +2107,7 @@ async fn generate_deno_lock(
.stderr(Stdio::piped())
.spawn()?;
handle_child(job_id, db, logs, timeout, child).await?;
handle_child(job_id, db, logs, timeout, * child).await?;
let path_lock = format!("{job_dir}/lock.json");
let mut file = File::open(path_lock).await?;
@@ -1996,7 +2156,14 @@ async fn pip_compile(
requirements: &str,
logs: &mut String,
job_dir: &str,
Envs { pip_extra_index_url, pip_index_url, pip_trusted_host, .. }: &Envs,
Envs {
pip_extra_index_url,
pip_index_url,
pip_trusted_host,
pip_local_dependencies,
..
}: &Envs,
db: &Pool<Postgres>,
timeout: i32,
) -> error::Result<String> {
@@ -2004,6 +2171,15 @@ async fn pip_compile(
set_logs(logs, job_id, db).await;
logs.push_str(&format!("\ncontent of requirements:\n{}", requirements));
let file = "requirements.in";
let requirements = if let Some(pip_local_dependencies) = pip_local_dependencies {
let deps = pip_local_dependencies.clone();
requirements
.lines()
.filter(|s| !deps.contains(&s.to_string()))
.join("\n")
} else {
requirements.to_string()
};
write_file(job_dir, file, &requirements).await?;
let mut args = vec!["-q", "--no-header", file, "--resolver=backtracking"];
@@ -2022,7 +2198,7 @@ async fn pip_compile(
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
handle_child(job_id, db, logs, timeout, child)
handle_child(job_id, db, logs, timeout, child, false)
.await
.map_err(|e| Error::ExecutionErr(format!("Lock file generation failed: {e:?}")))?;
let path_lock = format!("{job_dir}/requirements.txt");
@@ -2057,7 +2233,7 @@ async fn install_go_dependencies(
.stderr(Stdio::piped())
.spawn()?;
handle_child(job_id, db, logs, timeout, child).await?;
handle_child(job_id, db, logs, timeout, child, false).await?;
}
let child = Command::new(go_path)
.current_dir(job_dir)
@@ -2065,7 +2241,7 @@ async fn install_go_dependencies(
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
handle_child(job_id, db, logs, timeout, child)
handle_child(job_id, db, logs, timeout, child, false)
.await
.map_err(|e| Error::ExecutionErr(format!("Lock file generation failed: {e:?}")))?;
@@ -2135,6 +2311,30 @@ async fn get_reserved_variables(
.collect())
}
async fn get_mem_peak(pid: Option<u32>, nsjail: bool) -> i32 {
if pid.is_none() {
return -1
}
let pid = if nsjail {
// This is a bit hacky, but the process id of the nsjail process is the pid of nsjail + 1.
// Ideally, we would get the number from fork() itself. This works in MOST cases.
pid.unwrap() + 1
} else {
pid.unwrap()
};
if let Ok(file) = File::open(format!("/proc/{}/status", pid)).await {
let mut lines = BufReader::new(file).lines();
while let Some(line) = lines.next_line().await.unwrap_or(None) {
if line.starts_with("VmHWM:") {
return line.split_whitespace().nth(1).and_then(|s| s.parse::<i32>().ok()).unwrap_or(-1);
};
}
-2
} else {
-3
}
}
/// - wait until child exits and return with exit status
/// - read lines from stdout and stderr and append them to the "queue"."logs"
/// quitting early if output exceedes MAX_LOG_SIZE characters (not bytes)
@@ -2148,12 +2348,20 @@ async fn handle_child(
logs: &mut String,
timeout: i32,
mut child: Child,
nsjail: bool,
) -> error::Result<()> {
let timeout = Duration::from_secs(u64::try_from(timeout).expect("invalid timeout"));
let ping_interval = Duration::from_secs(5);
let cancel_check_interval = Duration::from_millis(500);
let update_job_interval = Duration::from_millis(500);
let write_logs_delay = Duration::from_millis(500);
let pid = child.id();
if let Some(pid) = pid {
//set the highest oom priority
let mut file = File::create(format!("/proc/{pid}/oom_score_adj")).await?;
let _ = file.write_all(b"1000").await;
} else {
tracing::info!("could not get child pid");
}
let (set_too_many_logs, mut too_many_logs) = watch::channel::<bool>(false);
let output = child_joined_output_stream(&mut child);
@@ -2163,17 +2371,20 @@ async fn handle_child(
/* the cancellation future is polled on by `wait_on_child` while
* waiting for the child to exit normally */
let cancel_check = async {
let update_job = async {
let db = db.clone();
let mut interval = interval(cancel_check_interval);
let mut interval = interval(update_job_interval);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select!(
_ = rx.recv() => break,
_ = interval.tick() => {
if sqlx::query_scalar!("SELECT canceled FROM queue WHERE id = $1", job_id)
let mem_peak = get_mem_peak(pid, nsjail).await;
tracing::info!("{job_id} still running. mem peak: {}kB", mem_peak);
let mem_peak = if mem_peak > 0 { Some(mem_peak) } else { None };
if sqlx::query_scalar!("UPDATE queue SET mem_peak = GREATEST($1, mem_peak), last_ping = now() WHERE id = $2 RETURNING canceled", mem_peak, job_id)
.fetch_optional(&db)
.await
.map(|v| Some(true) == v)
@@ -2203,7 +2414,7 @@ async fn handle_child(
biased;
result = child.wait() => return result.map(Ok),
Ok(()) = too_many_logs.changed() => KillReason::TooManyLogs,
_ = cancel_check => KillReason::Cancelled,
_ = update_job => KillReason::Cancelled,
_ = sleep(timeout) => KillReason::Timeout,
};
tx.send(()).await.expect("rx should never be dropped");
@@ -2237,8 +2448,13 @@ async fn handle_child(
/* a future that reads output from the child and appends to the database */
let lines = async move {
let max_log_size = if *CLOUD_HOSTED {
MAX_RESULT_SIZE
} else {
usize::MAX
};
/* log_remaining is zero when output limit was reached */
let mut log_remaining = (MAX_LOG_SIZE as usize).saturating_sub(logs.chars().count());
let mut log_remaining = max_log_size.saturating_sub(logs.chars().count());
let mut result = io::Result::Ok(());
let mut output = output;
/* `do_write` resolves the task, but does not contain the Result.
@@ -2269,7 +2485,7 @@ async fn handle_child(
tracing::info!(%job_id, "Too many logs lines for job {job_id}");
let _ = set_too_many_logs.send(true);
joined.push_str(&format!(
"Job logs or result reached character limit of {MAX_LOG_SIZE}; killing job."
"Job logs or result reached character limit of {MAX_RESULT_SIZE}; killing job."
));
/* stop reading and drop our streams fairly quickly */
break;
@@ -2326,45 +2542,25 @@ async fn handle_child(
}
}.instrument(trace_span!("child_lines"));
/* a stream updating "queue"."last_ping" at an interval */
let (kill_tx, mut kill_rx) = oneshot::channel::<()>();
let mut interval = interval(ping_interval);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let db1 = db.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(err) = sqlx::query!("UPDATE queue SET last_ping = now() WHERE id = $1", job_id)
.execute(&db1)
.await
{
tracing::error!(%job_id, %err, "error setting last ping for job {job_id}: {err}");
};
},
_ = (&mut kill_rx) => return,
}
}
});
let (wait_result, _) = tokio::join!(wait_on_child, lines);
kill_tx.send(()).expect("send should always work");
match wait_result {
_ if *too_many_logs.borrow() => Err(Error::ExecutionErr(
"logs or result reached limit".to_string(),
)),
_ if *too_many_logs.borrow() => Err(Error::ExecutionErr(format!(
"logs or result reached limit. (current max size: {MAX_RESULT_SIZE} characters)"
))),
Ok(Ok(status)) => {
if status.success() {
Ok(())
} else if let Some(code) = status.code() {
Err(error::Error::ExitStatus(code))
} else {
Err(error::Error::ExecutionErr(
"process terminated by signal".to_string(),
))
Err(error::Error::ExecutionErr(format!(
"process terminated by signal: {:#?}, stopped_signal: {:#?}, core_dumped: {}",
status.signal(),
status.stopped_signal(),
status.core_dumped()
)))
}
}
Ok(Err(kill_reason)) => Err(Error::ExecutionErr(format!(
@@ -2531,7 +2727,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, timeout: i32, base_url: &str) {
&job.permissioned_as,
"ephemeral-zombie-jobs",
timeout * 2,
&job.created_by,
&job.email,
)
.await
.expect("could not create job token");
@@ -2564,6 +2760,7 @@ async fn handle_python_reqs(
pip_extra_index_url,
pip_trusted_host,
nsjail_path,
..
}: &Envs,
job: &QueuedJob,
@@ -2658,7 +2855,7 @@ async fn handle_python_reqs(
.spawn()?
};
let child = handle_child(&job.id, db, logs, timeout, child).await;
let child = handle_child(&job.id, db, logs, timeout, child, false).await;
tracing::info!(
worker_name = %worker_name,
job_id = %job.id,

View File

@@ -303,7 +303,6 @@ pub async fn update_flow_status_after_job_completion(
.fetch_one(&mut tx)
.await?;
tracing::info!("setting failure module for flow XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX {parent_module:?}");
sqlx::query!(
"
UPDATE queue
@@ -403,7 +402,7 @@ pub async fn update_flow_status_after_job_completion(
{
true
}
false if has_failure_module(flow, &mut tx).await? => true,
false if has_failure_module(flow, &mut tx).await? && !is_failure_step => true,
false => false,
};
@@ -428,15 +427,17 @@ pub async fn update_flow_status_after_job_completion(
"Flow job canceled".to_string()
} else if stop_early {
format!("Flow job stopped early because of a stop early predicate returning true")
} else if success {
"Flow job completed with success".to_string()
} else {
"Flow job completed".to_string()
"Flow job completed with error".to_string()
};
if flow_job.canceled {
add_completed_job_error(
db,
&flow_job,
logs,
&canceled_job_to_result(&flow_job),
canceled_job_to_result(&flow_job),
metrics.clone(),
)
.await?;
@@ -469,7 +470,7 @@ pub async fn update_flow_status_after_job_completion(
db,
&flow_job,
"Unexpected error during flow chaining:\n".to_string(),
err,
json!({"message": err.to_string(), "name": "InternalError"}),
metrics.clone(),
)
.await;
@@ -687,13 +688,20 @@ async fn transform_input(
}
}
let lresult = last_result.clone();
let error = if let Some(error) = lresult.as_object() {
error.get("error")
} else {
None
};
for (key, val) in input_transforms.into_iter() {
match val {
InputTransform::Static { value: _ } => (),
InputTransform::Javascript { expr } => {
let flow_input = flow_args.clone().unwrap_or_else(|| json!({}));
let previous_result = last_result.clone();
let context = vec![
let mut context = vec![
("params".to_string(), json!(mapped)),
("previous_result".to_string(), previous_result),
("flow_input".to_string(), flow_input),
@@ -705,6 +713,10 @@ async fn transform_input(
("approvers".to_string(), json!(approvers.clone())),
];
if error.is_some() {
context.push(("error".to_string(), error.unwrap().clone()));
}
let v = eval_timeout(
expr.to_string(),
context,
@@ -976,7 +988,7 @@ async fn push_next_flow_job(
let success = false;
let skipped = false;
let logs = "Timed out waiting to be resumed".to_string();
let result = json!({ "error": logs });
let result = json!({ "error": {"message": logs, "name": "SuspendedTimeout"}});
let _uuid =
add_completed_job(db, &flow_job, success, skipped, result, logs).await?;
@@ -1871,7 +1883,7 @@ async fn get_transform_context(
&flow_job.permissioned_as,
"transform-input",
10,
&flow_job.created_by,
&flow_job.email,
)
.await?;
//we need to commit asap otherwise the token won't be valid for auth to check outside of this transaction

View File

@@ -1,10 +1,12 @@
// deno-lint-ignore-file no-explicit-any
import { colors, setClient } from "./deps.ts";
import { tryGetLoginInfo } from "./login.ts";
import { colors, setClient, UserService } from "./deps.ts";
import { loginInteractive, tryGetLoginInfo } from "./login.ts";
import { GlobalOptions } from "./types.ts";
import {
addWorkspace,
getActiveWorkspace,
getWorkspaceByName,
removeWorkspace,
Workspace,
} from "./workspace.ts";
@@ -67,6 +69,27 @@ export async function requireLogin(opts: GlobalOptions) {
}
setClient(token, workspace.remote.substring(0, workspace.remote.length - 1));
try {
await UserService.globalWhoami();
} catch {
console.log(
"! Could not reach API given existing credentials. Attempting to reauth...",
);
const newToken = await loginInteractive(workspace.remote);
if (!newToken) {
throw new Error("Could not reauth");
}
removeWorkspace(workspace.name);
workspace.token = newToken;
addWorkspace(workspace);
setClient(
token,
workspace.remote.substring(0, workspace.remote.length - 1),
);
await UserService.globalWhoami();
}
}
export async function tryResolveVersion(

8
cli/decoverto.ts Normal file
View File

@@ -0,0 +1,8 @@
// globally shared decoverto instance
import { Decoverto } from "npm:decoverto";
const decoverto = new Decoverto();
// TODO: Properly type FlowModule
export { Any, array, map, MapShape, model, property } from "npm:decoverto";
export { decoverto };

View File

@@ -1,32 +1,40 @@
// windmill
export { setClient } from "https://deno.land/x/windmill@v1.56.0/mod.ts";
export * from "https://deno.land/x/windmill@v1.56.0/windmill-api/index.ts";
export { setClient } from "https://deno.land/x/windmill@v1.61.1/mod.ts";
export * from "https://deno.land/x/windmill@v1.61.1/windmill-api/index.ts";
// cliffy
export { Command } from "https://deno.land/x/cliffy@v0.25.6/command/command.ts";
export { Table } from "https://deno.land/x/cliffy@v0.25.6/table/table.ts";
export { colors } from "https://deno.land/x/cliffy@v0.25.6/ansi/colors.ts";
export { Secret } from "https://deno.land/x/cliffy@v0.25.6/prompt/secret.ts";
export { Select } from "https://deno.land/x/cliffy@v0.25.6/prompt/select.ts";
export { Confirm } from "https://deno.land/x/cliffy@v0.25.6/prompt/confirm.ts";
export { Input } from "https://deno.land/x/cliffy@v0.25.6/prompt/input.ts";
export { Command } from "https://deno.land/x/cliffy@v0.25.7/command/command.ts";
export { Table } from "https://deno.land/x/cliffy@v0.25.7/table/table.ts";
export { colors } from "https://deno.land/x/cliffy@v0.25.7/ansi/colors.ts";
export { Secret } from "https://deno.land/x/cliffy@v0.25.7/prompt/secret.ts";
export { Select } from "https://deno.land/x/cliffy@v0.25.7/prompt/select.ts";
export { Confirm } from "https://deno.land/x/cliffy@v0.25.7/prompt/confirm.ts";
export { Input } from "https://deno.land/x/cliffy@v0.25.7/prompt/input.ts";
export {
DenoLandProvider,
UpgradeCommand,
} from "https://deno.land/x/cliffy@v0.25.6/command/upgrade/mod.ts";
} from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
// std
export { Untar } from "https://deno.land/std@0.170.0/archive/untar.ts";
export * as path from "https://deno.land/std@0.170.0/path/mod.ts";
export { ensureDir } from "https://deno.land/std@0.170.0/fs/ensure_dir.ts";
export { Untar } from "https://deno.land/std@0.176.0/archive/untar.ts";
export * as path from "https://deno.land/std@0.176.0/path/mod.ts";
export { ensureDir } from "https://deno.land/std@0.176.0/fs/ensure_dir.ts";
export {
copy,
readAll,
readerFromStreamReader,
} from "https://deno.land/std@0.170.0/streams/mod.ts";
export { DelimiterStream } from "https://deno.land/std@0.170.0/streams/mod.ts";
} from "https://deno.land/std@0.176.0/streams/mod.ts";
export { DelimiterStream } from "https://deno.land/std@0.176.0/streams/mod.ts";
export { iterateReader } from "https://deno.land/std@0.176.0/streams/iterate_reader.ts";
// other
export { getAvailablePort } from "https://deno.land/x/port@1.0.0/mod.ts";
export { default as dir } from "https://deno.land/x/dir@1.5.1/mod.ts";
export { passwordGenerator } from "https://deno.land/x/password_generator@latest/mod.ts"; // TODO: I think the version is called latest, but it's still pinned.
export { nanoid } from "https://deno.land/x/nanoid@v3.0.0/mod.ts";
export * as cbor from "https://deno.land/x/cbor@v1.4.1/index.js";
export { default as Murmurhash3 } from "https://deno.land/x/murmurhash@v1.0.0/mod.ts";
export {
default as microdiff,
} from "https://deno.land/x/microdiff@v1.3.1/index.ts";
export { default as objectHash } from "https://deno.land/x/object_hash@2.0.3.1/mod.ts";

View File

@@ -1,16 +1,145 @@
// deno-lint-ignore-file no-explicit-any
import { GlobalOptions } from "./types.ts";
import {
Difference,
GlobalOptions,
PushDiffs,
Resource,
setValueByPath,
} from "./types.ts";
import {
colors,
Command,
Flow,
FlowModule,
FlowService,
JobService,
OpenFlow,
microdiff,
OpenFlowWPath,
Table,
} from "./deps.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import { resolve, track_job } from "./script.ts";
import { Any, array, decoverto, model, property } from "./decoverto.ts";
@model()
export class FlowValueFilePart {
@property(array(Any))
modules: Array<FlowModule>;
@property(Any)
failure_module?: FlowModule;
@property(() => Boolean)
same_worker?: boolean;
constructor(modules: Array<FlowModule>) {
this.modules = modules;
}
}
// this is effectively "OpenFlow" but a copy as it is accepted by the CLI
@model()
export class FlowFile implements Resource, PushDiffs {
@property(() => String)
summary: string;
@property(() => String)
description?: string;
@property(() => FlowValueFilePart)
value: FlowValueFilePart;
@property(Any)
schema?: any;
constructor(summary: string, value: FlowValueFilePart) {
this.summary = summary;
this.value = value;
}
async pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (
await FlowService.existsFlowByPath({
workspace: workspace,
path: remotePath,
})
) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing flow...`,
),
);
// TODO: Make these optional in backend (not path ofc)
const changeset: OpenFlowWPath = {
path: remotePath,
summary: this.summary,
value: this.value,
description: this.description, // This is OpenAPIed as optional, but isn't
schema: this.schema, // Same
};
for (const diff of diffs) {
if (
diff.type !== "REMOVE" &&
(
diff.path[0] !== "value" && (
diff.path.length !== 1 ||
!["summary", "description", "schema"].includes(
diff.path[0] as string,
)
)
)
) {
throw new Error("Invalid flow diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
setValueByPath(changeset, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(changeset, diff.path, null);
}
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
if (!hasChanges) {
console.log(colors.yellow("! Skipping empty changeset"));
return;
}
await FlowService.updateFlow({
workspace: workspace,
path: remotePath,
requestBody: changeset,
});
} else {
console.log(colors.bold.yellow("Creating new flow..."));
await FlowService.createFlow({
workspace: workspace,
requestBody: {
path: remotePath,
summary: this.summary,
value: this.value,
schema: this.schema,
description: this.description,
},
});
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let remote: Flow | undefined;
try {
remote = await FlowService.getFlowByPath({
workspace,
path: remotePath,
});
} catch {
remote = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(remote ?? {}, this, { cyclesFix: false }),
);
}
}
type Options = GlobalOptions;
@@ -30,38 +159,10 @@ export async function pushFlow(
workspace: string,
remotePath: string,
) {
const data: OpenFlow = JSON.parse(await Deno.readTextFile(filePath));
if (
await FlowService.existsFlowByPath({
workspace: workspace,
path: remotePath,
})
) {
console.log(colors.bold.yellow("Updating existing flow..."));
await FlowService.updateFlow({
workspace: workspace,
path: remotePath,
requestBody: {
path: remotePath,
summary: data.summary,
value: data.value,
schema: data.schema,
description: data.description,
},
});
} else {
console.log(colors.bold.yellow("Creating new flow..."));
await FlowService.createFlow({
workspace: workspace,
requestBody: {
path: remotePath,
summary: data.summary,
value: data.value,
schema: data.schema,
description: data.description,
},
});
}
const data = decoverto.type(FlowFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, remotePath);
}
async function list(opts: GlobalOptions & { showArchived?: boolean }) {

View File

@@ -1,6 +1,122 @@
import { colors, Command, Folder, FolderService } from "./deps.ts";
import { colors, Command, Folder, FolderService, microdiff } from "./deps.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import { GlobalOptions } from "./types.ts";
import {
Difference,
GlobalOptions,
PushDiffs,
Resource,
setValueByPath,
} from "./types.ts";
import {
array,
decoverto,
map,
MapShape,
model,
property,
} from "./decoverto.ts";
@model()
export class FolderFile implements Resource, PushDiffs {
@property(array(() => String))
owners: Array<string> | undefined;
@property(map(() => String, () => Boolean, { shape: MapShape.Object }))
extra_perms: Map<string, boolean> | undefined;
async push(workspace: string, remotePath: string): Promise<void> {
if (remotePath.startsWith("/")) {
remotePath = remotePath.substring(1);
}
if (remotePath.startsWith("f/")) {
remotePath = remotePath.substring(2);
}
let existing: Folder | undefined;
try {
existing = await FolderService.getFolder({ workspace, name: remotePath });
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
);
}
async pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (remotePath.startsWith("/")) {
remotePath = remotePath.substring(1);
}
if (remotePath.startsWith("f/")) {
remotePath = remotePath.substring(2);
}
// TODO: Support this in backend
let exists: boolean;
try {
exists = !!await FolderService.getFolder({ workspace, name: remotePath });
} catch {
exists = false;
}
if (exists) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing folder...`,
),
);
const changeset: {
owners?: string[] | undefined;
extra_perms?: any;
} = {};
for (const diff of diffs) {
if (
diff.type !== "REMOVE" &&
(
diff.path.length !== 1 ||
!["owners", "extra_perms"].includes(diff.path[0] as string)
)
) {
throw new Error("Invalid folder diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
setValueByPath(changeset, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(changeset, diff.path, null);
}
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
if (!hasChanges) {
console.log(colors.yellow("! Skipping empty changeset"));
return;
}
await FolderService.updateFolder({
workspace: workspace,
name: remotePath,
requestBody: changeset,
});
} else {
console.log(colors.bold.yellow("Creating new folder..."));
await FolderService.createFolder({
workspace: workspace,
requestBody: {
name: remotePath,
extra_perms: this.extra_perms,
owners: this.owners,
},
});
}
}
}
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
@@ -21,61 +137,15 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
console.log(colors.bold.underline.green("Resource successfully pushed"));
}
type FolderFile = {
owners: Array<string> | undefined;
extra_perms: Record<string, boolean> | undefined;
};
export async function pushFolder(
workspace: string,
filePath: string,
remotePath: string,
) {
if (remotePath.startsWith("/")) {
remotePath = remotePath.substring(1);
}
if (remotePath.startsWith("f/")) {
remotePath = remotePath.substring(2);
}
const data: FolderFile = JSON.parse(await Deno.readTextFile(filePath));
let optFolder: Folder | undefined;
try {
optFolder = await FolderService.getFolder({ workspace, name: remotePath });
} catch {
optFolder = undefined;
}
if (optFolder) {
// for (const [k, v] of Object.entries(optFolder.extra_perms)) {
// if (!data.extra_perms || data.extra_perms[k] !== v) {
// console.log(colors.red.underline.bold(`Extra Perms missmatch on ${k}`));
// return;
// }
// }
console.log(colors.yellow("Updating existing folder..."));
await FolderService.updateFolder({
workspace,
name: remotePath,
requestBody: {
extra_perms: data.extra_perms,
owners: data.owners,
},
});
} else {
console.log(colors.yellow("Creating new folder..."));
await FolderService.createFolder({
workspace,
requestBody: {
name: remotePath,
extra_perms: data.extra_perms,
owners: data.owners,
},
});
// HACK: Workaround backend automatically adding current user to folder.
await pushFolder(workspace, filePath, remotePath);
}
const data = decoverto.type(FolderFile).rawToInstance(
await Deno.readTextFile(filePath),
);
data.push(workspace, remotePath);
}
const command = new Command()

View File

@@ -1,6 +1,6 @@
import { Command } from "./deps.ts";
import { requireLogin, resolveWorkspace } from "./context.ts";
import { pushResourceTypeDef } from "./resource-type.ts";
import { ResourceTypeFile } from "./resource-type.ts";
import { GlobalOptions } from "./types.ts";
async function pull(opts: GlobalOptions) {
@@ -56,14 +56,10 @@ async function pull(opts: GlobalOptions) {
const x of list
) {
console.log("syncing " + x.name);
await pushResourceTypeDef(
workspace.workspaceId,
x.name,
{
description: x.description,
schema: JSON.parse(x.schema),
},
);
const f = new ResourceTypeFile();
f.description = x.description;
f.schema = JSON.parse(x.schema);
await f.push(workspace.workspaceId, x.name);
}
}

View File

@@ -8,13 +8,14 @@ import variable from "./variable.ts";
import push from "./push.ts";
import pull from "./pull.ts";
import hub from "./hub.ts";
// import folder from "./folder.ts";
import folder from "./folder.ts";
import sync from "./sync.ts";
import { tryResolveVersion } from "./context.ts";
import { GlobalOptions } from "./types.ts";
const VERSION = "v1.57.0";
const VERSION = "v1.62.0";
const command = new Command()
let command: any = new Command()
.name("wmill")
.description("A simple CLI tool for windmill.")
.globalOption(
@@ -32,10 +33,9 @@ const command = new Command()
.command("resource", resource)
.command("user", user)
.command("variable", variable)
.command("push", push)
.command("pull", pull)
.command("hub", hub)
// .command("folder", folder)
.command("folder", folder)
.command("sync", sync)
.command("version", "Show version information")
.action(async (opts) => {
console.log("CLI build against " + VERSION);
@@ -61,6 +61,12 @@ const command = new Command()
}),
);
if (Number.parseInt(VERSION.replace("v", "").replace(".", "")) > 1700) {
command = command
.command("push", push)
.command("pull", pull);
}
try {
await command.parse(Deno.args);
} catch (e) {

View File

@@ -1,20 +1,11 @@
// deno-lint-ignore-file no-explicit-any
import { resolveWorkspace } from "./context.ts";
import { GlobalOptions } from "./types.ts";
import {
colors,
Command,
Confirm,
copy,
ensureDir,
path,
readerFromStreamReader,
Untar,
} from "./deps.ts";
async function pull(opts: GlobalOptions & { override: boolean }, dir: string) {
const workspace = await resolveWorkspace(opts);
import { colors, Command, readerFromStreamReader, Untar } from "./deps.ts";
import { Workspace } from "./workspace.ts";
export async function downloadTar(
workspace: Workspace,
): Promise<Untar | undefined> {
const requestHeaders: HeadersInit = new Headers();
requestHeaders.set("Authorization", "Bearer " + workspace.token);
requestHeaders.set("Content-Type", "application/octet-stream");
@@ -35,51 +26,29 @@ async function pull(opts: GlobalOptions & { override: boolean }, dir: string) {
),
);
console.log(await tarResponse.text());
return;
return undefined;
}
const streamReader = tarResponse.body?.getReader();
if (!streamReader) {
console.log(colors.red("Failed to read tar request body"));
return;
return undefined;
}
console.log(colors.yellow("Streaming tarball to disk..."));
const denoReader = readerFromStreamReader(streamReader);
const untar = new Untar(denoReader);
for await (const entry of untar) {
console.log(entry.fileName);
const filePath = path.resolve(dir, entry.fileName);
if (entry.type === "directory") {
await ensureDir(filePath);
continue;
}
await ensureDir(path.dirname(filePath));
if (!opts.override) {
let exists = false;
try {
const _stat = await Deno.stat(filePath);
exists = true;
} catch {
exists = false;
}
if (exists) {
if (
!(await Confirm.prompt(
"Conflict at " +
filePath +
" do you want to override the local version?",
))
) {
continue;
}
}
}
const file = await Deno.open(filePath, { write: true, create: true });
const len = await copy(entry, file);
await file.truncate(len);
file.close();
}
console.log(colors.green("Done. Wrote all files to disk."));
return untar;
}
async function stub(
_opts: GlobalOptions & { override: boolean },
_dir: string,
) {
console.log(
colors.red.underline(
'Pull is deprecated. Use "sync pull --raw" instead. See <TODO_LINK_HERE> for more information.',
),
);
}
const command = new Command()
@@ -87,6 +56,6 @@ const command = new Command()
"Pull all definitions in the current workspace from the API and write them to disk.",
)
.arguments("<dir:string>")
.action(pull as any);
.action(stub as any);
export default command;

View File

@@ -1,266 +1,21 @@
// deno-lint-ignore-file no-explicit-any
import { colors, Command, path } from "./deps.ts";
import { requireLogin, resolveWorkspace } from "./context.ts";
import { pushFlow } from "./flow.ts";
import { pushResource } from "./resource.ts";
import { findContentFile, pushScript } from "./script.ts";
import { colors, Command } from "./deps.ts";
import { GlobalOptions } from "./types.ts";
import { pushVariable } from "./variable.ts";
import { pushResourceType } from "./resource-type.ts";
import { pushFolder } from "./folder.ts";
type Candidate = {
path: string;
namespaceKind: "user" | "group" | "folder";
namespaceName: string;
};
type ResourceTypeCandidate = {
path: string;
};
type FolderCandidate = {
path: string;
namespaceName: string;
};
async function findCandidateFiles(
dir: string,
): Promise<
{
normal: Candidate[];
resourceTypes: ResourceTypeCandidate[];
folders: FolderCandidate[];
}
> {
dir = path.resolve(dir);
if (path.dirname(dir).startsWith(".")) {
return { normal: [], resourceTypes: [], folders: [] };
}
const normalCandidates: Candidate[] = [];
const resourceTypeCandidates: ResourceTypeCandidate[] = [];
const folderCandidates: FolderCandidate[] = [];
for await (const e of Deno.readDir(dir)) {
if (e.isDirectory) {
if (e.name == "u" || e.name == "g" || e.name == "f") { // TODO: Check version for f
const newDir = dir + (dir.endsWith("/") ? "" : "/") + e.name;
for await (const e2 of Deno.readDir(newDir)) {
if (e2.isDirectory) {
if (e2.name.startsWith(".")) continue;
const namespaceName = e2.name;
const stack: string[] = [];
{
const path = newDir + "/" + namespaceName + "/";
stack.push(path);
try {
await Deno.stat(path + "folder.meta.json");
folderCandidates.push({
namespaceName,
path: path + "folder.meta.json",
});
} catch {}
}
while (stack.length > 0) {
const dir2 = stack.pop()!;
for await (const e3 of Deno.readDir(dir2)) {
if (e3.isFile) {
if (e3.name === "folder.meta.json") continue;
normalCandidates.push({
path: dir2 + e3.name,
namespaceKind: e.name == "g"
? "group"
: e.name == "u"
? "user"
: "folder",
namespaceName: namespaceName,
});
} else {
stack.push(dir2 + e3.name + "/");
}
}
}
}
}
} else {
console.log(
colors.yellow(
"Including organizational folder " + e.name + " in push!",
),
);
const { normal, resourceTypes, folders } = await findCandidateFiles(
path.join(dir, e.name),
);
normalCandidates.push(...normal);
resourceTypeCandidates.push(...resourceTypes);
folderCandidates.push(...folders);
}
} else {
// handle root files
if (e.name.endsWith(".resource-type.json")) {
resourceTypeCandidates.push({
path: dir + (dir.endsWith("/") ? "" : "/") + e.name,
});
}
}
}
return {
normal: normalCandidates,
folders: folderCandidates,
resourceTypes: resourceTypeCandidates,
};
}
async function push(opts: GlobalOptions, dir?: string) {
dir = dir ?? Deno.cwd();
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
console.log(colors.blue("Searching Directory..."));
const { normal, resourceTypes, folders } = await findCandidateFiles(dir);
async function stub(
_opts: GlobalOptions,
_dir?: string,
) {
console.log(
colors.blue(
"Found " + (normal.length + resourceTypes.length + folders.length) +
" candidates",
colors.red.underline(
'Push is deprecated. Use "sync push --raw" instead. See <TODO_LINK_HERE> for more information.',
),
);
for (const resourceType of resourceTypes) {
const fileName = resourceType.path.substring(
resourceType.path.lastIndexOf("/") + 1,
);
const fileNameParts = fileName.split(".");
// invalid file names, like my.cool.script.script.json. Not valid.
if (fileNameParts.length != 3) {
console.log(
colors.yellow("invalid file name found at " + resourceType.path),
);
continue;
}
// filter out non-json files. Note that we filter out script contents above, so this is really an error.
if (fileNameParts.at(-1) != "json") {
console.log(colors.yellow("non-JSON file found at " + resourceType.path));
continue;
}
console.log("pushing resource type " + fileNameParts.at(-3)!);
await pushResourceType(
workspace.workspaceId,
resourceType.path,
fileNameParts.at(-3)!,
);
}
for (const folder of folders) {
await pushFolder(
workspace.workspaceId,
folder.path,
"f/" + folder.namespaceName,
);
}
for (const candidate of normal) {
// full file name. No leading /. includes .type.json
const fileName = candidate.path.substring(
candidate.path.lastIndexOf("/") + 1,
);
// figure out just the path after ...../u|g/username|group/ (in extra dir)
const dirParts = candidate.path.split("/").filter((x) => x.length > 0);
// TODO: check version for folder
const gIndex = dirParts.findIndex((x) => x == "u" || x == "g" || x == "f");
const extraDir = dirParts.slice(gIndex + 2, -1).join("/");
// file name parts has .json (hopefully) at -1, type at -2, and the actual name at -3. Dots in names are not allowed.
const fileNameParts = fileName.split(".");
// filter out script content files
if (
fileNameParts.at(-1) == "ts" ||
fileNameParts.at(-1) == "py" ||
fileNameParts.at(-1) == "go"
) {
// probably part of a script. Silent ignore.
continue;
}
// invalid file names, like my.cool.script.script.json. Not valid.
if (fileNameParts.length != 3) {
console.log(
colors.yellow("invalid file name found at " + candidate.path),
);
continue;
}
// filter out non-json files. Note that we filter out script contents above, so this is really an error.
if (fileNameParts.at(-1) != "json") {
console.log(colors.yellow("non-JSON file found at " + candidate.path));
continue;
}
// get the type & filter it for valid ones.
const type = fileNameParts.at(-2);
if (type == "resource-type") {
console.log(
colors.yellow(
"Found resource type file at " +
candidate.path +
" this appears to be inside a path folder. Resource types are not addressed by path. Place them at the root or inside only an organizational folder. Ignoring this file!",
),
);
continue;
}
if (
type != "flow" &&
type != "resource" &&
type != "script" &&
type != "variable"
) {
console.log(
colors.yellow(
"file with invalid type " + type + " found at " + candidate.path,
),
);
continue;
}
// create the remotePath for the API
const remotePath = (candidate.namespaceKind === "group"
? "g/"
: (candidate.namespaceKind === "user" ? "u/" : "f/")) +
candidate.namespaceName +
"/" +
(extraDir.length > 0 ? extraDir + "/" : "") +
fileNameParts.at(-3);
console.log("pushing " + type + " to " + remotePath);
if (type == "flow") {
await pushFlow(candidate.path, workspace.workspaceId, remotePath);
} else if (type == "resource") {
await pushResource(workspace.workspaceId, candidate.path, remotePath);
} else if (type == "script") {
let contentPath: string;
try {
contentPath = await findContentFile(candidate.path);
} catch (e) {
console.log(colors.red(e.toString()));
continue;
}
await pushScript(
candidate.path,
contentPath,
workspace.workspaceId,
remotePath,
);
} else if (type == "variable") {
await pushVariable(workspace.workspaceId, candidate.path, remotePath);
}
}
console.log(colors.underline.bold.green("Successfully Pushed all files."));
}
const command = new Command()
.description("Push all files from a folder")
.arguments("[dir:string]")
.action(push as any);
.action(stub as any);
export default command;

View File

@@ -1,65 +1,129 @@
// deno-lint-ignore-file no-explicit-any
import { GlobalOptions } from "./types.ts";
import {
Difference,
GlobalOptions,
PushDiffs,
Resource as ResourceI,
setValueByPath,
} from "./types.ts";
import { requireLogin, resolveWorkspace } from "./context.ts";
import { colors, Command, ResourceService, Table } from "./deps.ts";
import {
colors,
Command,
EditResourceType,
microdiff,
ResourceService,
ResourceType,
Table,
} from "./deps.ts";
import { Any, decoverto, model, property } from "./decoverto.ts";
type ResourceTypeFile = {
@model()
export class ResourceTypeFile implements ResourceI, PushDiffs {
@property(Any)
schema?: any;
@property(() => String)
description?: string;
};
async push(workspace: string, remotePath: string): Promise<void> {
let existing: ResourceType | undefined;
try {
existing = await ResourceService.getResourceType({
workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
);
}
async pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (
await ResourceService.existsResourceType({
workspace: workspace,
path: remotePath,
})
) {
if (
(await ResourceService.listResourceType({ workspace })).findIndex((x) =>
x.name === remotePath
) === -1
) {
console.log(
"Resource type " + remotePath +
" is already taken for the current workspace, but cannot be updated. Is this a conflict with starter?",
);
return;
}
console.log(
colors.yellow(
`Applying ${diffs.length} diffs to existing resource type...`,
),
);
const changeset: EditResourceType = {};
for (const diff of diffs) {
if (
diff.type !== "REMOVE" &&
(
diff.path.length !== 1 ||
!["schema", "description"].includes(diff.path[0] as string)
)
) {
throw new Error("Invalid resource type diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
setValueByPath(changeset, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(changeset, diff.path, null);
}
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
if (!hasChanges) {
console.log(colors.yellow("! Skipping empty changeset"));
return;
}
await ResourceService.updateResourceType({
workspace: workspace,
path: remotePath,
requestBody: changeset,
});
} else {
console.log(colors.yellow("Creating new resource type..."));
await ResourceService.createResourceType({
workspace: workspace,
requestBody: {
name: remotePath,
description: this.description,
schema: this.schema,
workspace_id: workspace,
},
});
}
}
}
export async function pushResourceType(
workspace: string,
filePath: string,
name: string,
) {
const data: ResourceTypeFile = JSON.parse(await Deno.readTextFile(filePath));
await pushResourceTypeDef(workspace, name, data);
}
export async function pushResourceTypeDef(
workspace: string,
name: string,
data: ResourceTypeFile,
) {
if (
await ResourceService.existsResourceType({
workspace: workspace,
path: name,
})
) {
console.log(colors.yellow("Updating existing resource type..."));
if (
(await ResourceService.listResourceType({ workspace })).findIndex((x) =>
x.name === name
) === -1
) {
console.log(
"Resource type " + name +
" is already taken for the current workspace, but cannot be updated. Is this a conflict with starter?",
);
return;
}
await ResourceService.updateResourceType({
workspace: workspace,
path: name,
requestBody: {
description: data.description,
schema: data.schema,
},
});
} else {
console.log(colors.yellow("Creating new resource type..."));
await ResourceService.createResourceType({
workspace: workspace,
requestBody: {
name: name,
description: data.description,
schema: data.schema,
workspace_id: workspace,
},
});
}
const data: ResourceTypeFile = decoverto.type(ResourceTypeFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, name);
}
type PushOptions = GlobalOptions;

View File

@@ -1,80 +1,142 @@
import { GlobalOptions } from "./types.ts";
import {
Difference,
GlobalOptions,
PushDiffs,
Resource as Resource2,
setValueByPath,
} from "./types.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import { colors, Command, Resource, ResourceService, Table } from "./deps.ts";
import {
colors,
Command,
EditResource,
microdiff,
Resource,
ResourceService,
Table,
} from "./deps.ts";
import { Any, decoverto, model, property } from "./decoverto.ts";
type ResourceFile = {
value: any;
@model()
export class ResourceFile implements Resource2, PushDiffs {
@property(Any)
value?: any;
@property(() => String)
description?: string;
@property(() => String)
resource_type: string;
@property(() => Boolean)
is_oauth?: boolean; // deprecated
};
constructor(resource_type: string) {
this.resource_type = resource_type;
}
async pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (
await ResourceService.existsResource({
workspace: workspace,
path: remotePath,
})
) {
console.log(
colors.yellow(`Applying ${diffs.length} diffs to existing resource...`),
);
const changeset: EditResource = {
path: remotePath, // TODO: Remove this in backend
};
for (const diff of diffs) {
if (diff.path[0] === "is_oauth") {
console.log(
colors.yellow(
"! is_oauth has been removed in newer versions. Ignoring.",
),
);
continue;
}
if (
diff.type !== "REMOVE" &&
(
diff.path[0] !== "value" && (
diff.path.length !== 1 ||
diff.path[0] !== "description"
)
)
) {
throw new Error("Invalid folder diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
setValueByPath(changeset, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(changeset, diff.path, null);
}
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
if (!hasChanges) {
console.log(colors.yellow("! Skipping empty changeset"));
return;
}
await ResourceService.updateResource({
workspace: workspace,
path: remotePath,
requestBody: changeset,
});
} else {
if (typeof this.is_oauth !== "undefined") {
console.log(
colors.yellow(
"! is_oauth has been removed in newer versions. Ignoring.",
),
);
}
console.log(colors.yellow("Creating new resource..."));
await ResourceService.createResource({
workspace: workspace,
requestBody: {
path: remotePath,
resource_type: this.resource_type,
value: this.value,
description: this.description,
},
});
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let existing: Resource | undefined;
try {
existing = await ResourceService.getResource({
workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
);
}
}
export async function pushResource(
workspace: string,
filePath: string,
remotePath: string,
) {
const data: ResourceFile = JSON.parse(await Deno.readTextFile(filePath));
if (
await ResourceService.existsResource({
workspace: workspace,
path: remotePath,
})
) {
console.log(colors.yellow("Updating existing resource..."));
const existing = await ResourceService.getResource({
workspace: workspace,
path: remotePath,
});
if (existing.resource_type != data.resource_type) {
console.log(
colors.red.underline.bold(
"Remote resource at " +
remotePath +
" exists & has a different resource type. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource.",
),
);
return;
}
if (typeof data.is_oauth !== "undefined") {
console.log(
colors.yellow(
"! is_oauth has been removed in newer versions. Ignoring.",
),
);
}
await ResourceService.updateResource({
workspace: workspace,
path: remotePath,
requestBody: {
path: remotePath,
value: data.value,
description: data.description,
},
});
} else {
if (typeof data.is_oauth !== "undefined") {
console.log(
colors.yellow(
"! is_oauth has been removed in newer versions. Ignoring.",
),
);
}
console.log(colors.yellow("Creating new resource..."));
await ResourceService.createResource({
workspace: workspace,
requestBody: {
path: remotePath,
resource_type: data.resource_type,
value: data.value,
description: data.description,
},
});
}
const data = decoverto.type(ResourceFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, remotePath);
}
type PushOptions = GlobalOptions;

View File

@@ -2,20 +2,52 @@
import { GlobalOptions } from "./types.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import {
colors,
Command,
JobService,
readAll,
Script,
} from "https://deno.land/x/windmill@v1.50.0/windmill-api/index.ts";
import { colors, Command, readAll, ScriptService, Table } from "./deps.ts";
ScriptService,
Table,
} from "./deps.ts";
import { Any, array, decoverto, model, property } from "./decoverto.ts";
type ScriptFile = {
@model()
export class ScriptFile {
@property(() => String)
parent_hash?: string;
@property(() => String)
summary: string;
@property(() => String)
description: string;
@property(Any)
schema?: any;
@property(() => Boolean)
is_template?: boolean;
@property(array(() => String))
lock?: Array<string>;
@property({
toInstance: (data) => {
if (data == null) return data;
if (
data === "script" || data === "failure" || data === "trigger" ||
data === "command" || data === "approvial"
) {
return data;
}
throw new Error("Invalid kind " + data);
},
toPlain: (data) => data,
})
kind?: "script" | "failure" | "trigger" | "command" | "approval";
};
constructor(summary: string, description: string) {
this.summary = summary;
this.description = description;
}
}
type PushOptions = GlobalOptions;
async function push(
@@ -48,10 +80,12 @@ async function push(
}
export async function findContentFile(filePath: string) {
console.log("Searching " + filePath);
const candidates = [
filePath.replace(".script.json", ".ts"),
filePath.replace(".script.json", ".py"),
filePath.replace(".script.json", ".go"),
filePath.replace(".script.json", ".sh"),
];
const validCandidates = (
await Promise.all(
@@ -79,23 +113,35 @@ export async function findContentFile(filePath: string) {
return validCandidates[0];
}
export function inferContentTypeFromFilePath(
contentPath: string,
): "python3" | "deno" | "go" | "bash" {
let language = contentPath.substring(contentPath.lastIndexOf("."));
if (language == ".ts") language = "deno";
if (language == ".py") language = "python3";
if (language == ".sh") language = "bash";
if (language == ".go") language = "go";
if (
language != "python3" && language != "deno" && language != "go" &&
language != "bash"
) {
throw new Error("Invalid language: " + language);
}
return language;
}
export async function pushScript(
filePath: string,
contentPath: string,
workspace: string,
remotePath: string,
) {
const data: ScriptFile = JSON.parse(await Deno.readTextFile(filePath));
const data = decoverto.type(ScriptFile).rawToInstance(
await Deno.readTextFile(filePath),
);
const content = await Deno.readTextFile(contentPath);
let language = contentPath.substring(contentPath.lastIndexOf("."));
if (language == ".ts") language = "deno";
if (language == ".py") language = "python3";
if (language == ".go") language = "go";
if (language != "python3" && language != "deno" && language != "go") {
throw new Error("Invalid language: " + language);
}
const language = inferContentTypeFromFilePath(contentPath);
let parent_hash = data.parent_hash;
if (!parent_hash) {
try {

969
cli/sync.ts Normal file
View File

@@ -0,0 +1,969 @@
import { requireLogin, resolveWorkspace } from "./context.ts";
import { getWorkspaceStream, Workspace } from "./workspace.ts";
import { decoverto, map, MapShape, model, property } from "./decoverto.ts";
import {
cbor,
colors,
Command,
Confirm,
copy,
ensureDir,
iterateReader,
microdiff,
nanoid,
objectHash,
path,
ScriptService,
} from "./deps.ts";
import {
Difference,
getTypeStrFromPath,
GlobalOptions,
inferTypeFromPath,
setValueByPath,
} from "./types.ts";
import { downloadTar } from "./pull.ts";
import { FolderFile } from "./folder.ts";
import { ResourceTypeFile } from "./resource-type.ts";
import {
findContentFile,
inferContentTypeFromFilePath,
pushScript,
ScriptFile,
} from "./script.ts";
import { ResourceFile } from "./resource.ts";
import { FlowFile } from "./flow.ts";
import { VariableFile } from "./variable.ts";
type TrackedId = string;
const TrackedId = String;
const CONTENT_ENCODER: cbor.Encoder = new cbor.Encoder({ pack: true });
export class Tracked {
#id: TrackedId;
#parent: State;
path: string;
#content_cache?: string;
constructor(id: TrackedId, parent: State, path: string) {
this.#id = id;
this.#parent = parent;
this.path = path;
}
async getContent(): Promise<unknown | undefined> {
if (this.#content_cache) {
return this.#content_cache;
}
if (!this.#parent.stateRoot) {
throw new Error("Parent uninitialized");
}
const f = this.#parent.contentFile(this.#id);
if (!f) {
return undefined;
}
const data = await Deno.readFile(
path.join(this.#parent.stateRoot, ".wmill", f),
);
const content = CONTENT_ENCODER.decode(data);
this.#content_cache = content;
return content;
}
getHash(): string {
return this.#parent.hashes.get(this.#id)!;
}
getId(): TrackedId {
return this.#id;
}
}
@model()
export class State {
// TODO: Handle script contents, as they are separate files & need to be tracked separately.
// I think the way this makes most sense is actually reating them as a separate kind of file everywhere
// And then move to using a diff-based push system everywhere. This should greatly simplify code & enable this easily
@property(map(() => TrackedId, () => String, { shape: MapShape.Object }))
hashes: Map<TrackedId, string>;
@property(map(() => TrackedId, () => String, { shape: MapShape.Object }))
contentFiles: Map<TrackedId, string>;
@property(map(() => String, () => TrackedId, { shape: MapShape.Object }))
tracked: Map<string, TrackedId>;
@property(() => String)
workspaceId: string;
@property(() => String)
remoteUrl: string;
stateRoot?: string;
constructor(
hashes: Map<TrackedId, string>,
tracked: Map<string, TrackedId>,
contentFiles: Map<TrackedId, string>,
workspaceId: string,
remoteUrl: string,
) {
this.hashes = hashes;
this.tracked = tracked;
this.contentFiles = contentFiles;
this.workspaceId = workspaceId;
this.remoteUrl = remoteUrl;
}
add(path: string) {
if (this.tracked.get(path)) {
throw new Error("Cannot newly track already tracked paths");
} else {
this.tracked.set(path, nanoid());
}
}
public forget(path: string) {
const id = this.tracked.get(path);
if (id) {
this.tracked.delete(path);
this.hashes.delete(id);
}
}
public contentFile(id: TrackedId): string | undefined {
return this.contentFiles.get(id);
}
public get(path: string): Tracked {
const id = this.tracked.get(path);
if (!id) {
throw new Error("Could not resolve path " + path);
}
return new Tracked(id, this, path);
}
public async save(): Promise<void> {
if (!this.stateRoot) {
throw new Error("Uninitialized state root");
}
const encoder = new cbor.Encoder({});
const plain = decoverto.type(State).instanceToPlain(this);
const result: Uint8Array = encoder.encode(plain, {});
await Deno.writeFile(path.join(this.stateRoot, ".wmill", "main"), result, {
create: true,
});
}
public static async loadState(dir: string): Promise<State> {
const source = await Deno.readFile(path.join(dir, ".wmill", "main"));
const encoder = new cbor.Encoder({});
const raw = encoder.decode(source);
const state = decoverto.type(State).plainToInstance(
raw,
);
state.stateRoot = dir;
return Object.freeze(state);
}
}
async function getState(opts: GlobalOptions) {
const existingState = await State.loadState(Deno.cwd());
const workspaceStream = await getWorkspaceStream();
const reader = workspaceStream.getReader();
while (true) {
const res = await reader.read();
if (res.value) {
if (
new URL(res.value.remote) == new URL(existingState.remoteUrl) &&
res.value.workspaceId == existingState.workspaceId
) {
opts.workspace = res.value.name;
(opts as any).__secret_workspace = res.value;
break;
}
}
if (res.done) {
break;
}
}
return existingState;
}
async function updateStateFromRemote(
workspace: Workspace,
state: State,
callback: (filename: string) => PromiseLike<boolean> | boolean,
) {
const untar = await downloadTar(workspace);
if (!untar) throw new Error("Failed to pull Tar");
const decoder = new TextDecoder();
for await (const entry of untar) {
const id = state.tracked.get(entry.fileName);
if (id) {
let val = "";
for await (const e of iterateReader(entry)) {
const tmp = decoder.decode(e);
val += tmp;
}
if (entry.fileName.endsWith(".json")) {
const parsed = JSON.parse(val);
const typed = inferTypeFromPath(entry.fileName, parsed);
const oldHash = state.hashes.get(id);
const newHash = objectHash(typed);
if (!oldHash || oldHash !== newHash) {
if (!await callback(entry.fileName)) {
return; // notice that we are not saving
}
state.hashes.set(id, newHash);
const encoded = CONTENT_ENCODER.encode(typed);
const fileName = nanoid();
await Deno.writeFile(
path.join(state.stateRoot!, ".wmill", fileName),
encoded,
{ create: true },
);
state.contentFiles.set(id, fileName);
}
} else {
const fileName = nanoid();
await Deno.writeTextFile(
path.join(state.stateRoot!, ".wmill", fileName),
val,
{ create: true },
);
state.contentFiles.set(id, fileName);
}
}
}
await state.save();
}
async function pull(
opts: GlobalOptions & { raw: boolean; rawOverride: boolean },
) {
if (opts.raw) {
const opts2 = opts as any;
opts2.override = opts2.rawOverride;
opts2.raw = undefined;
opts2.rawOverride = undefined;
await pullRaw(opts2, Deno.cwd());
return;
}
const state = await getState(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
console.log("Pulling remote changes");
await updateStateFromRemote(workspace, state, (_) => true);
const diffs = diffState(state);
await copyNonJsonFiles(state);
console.log(`Applying changes to files`);
for await (const diff of diffs) {
await applyDiff(
diff.diff,
path.join(state.stateRoot!, diff.localPath),
);
}
console.log(colors.green.underline("Done! All changes applied."));
async function applyDiff(diffs: Difference[], file: string) {
ensureDir(path.dirname(file));
let json;
try {
json = JSON.parse(await Deno.readTextFile(file));
} catch {
json = {};
}
// TODO: Delegate the below to the object itself
// This would work by infering the type of `JSON` (which includes then statically typing it using decoverto) and then
// delegating the applying of the diffs to the object via an interface
for (const diff of diffs) {
if (diff.type === "CREATE") {
setValueByPath(json, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(json, diff.path, undefined);
} else if (diff.type === "CHANGE") {
setValueByPath(json, diff.path, diff.value);
}
}
// TODO: Use decoverto instanceToPlain below.
await Deno.writeTextFile(file, JSON.stringify(json, undefined, " "), {
create: true,
});
}
async function copyNonJsonFiles(state: State): Promise<void> {
for (const t of state.tracked.keys()) {
if (t.endsWith(".json")) {
continue;
}
const entry = state.get(t);
const target = await Deno.open(entry.path, { create: true, write: true });
const source = await Deno.open(
path.join(
state.stateRoot!,
".wmill",
state.contentFile(entry.getId())!,
),
{
read: true,
write: false,
},
);
await source.readable.pipeTo(target.writable);
}
}
}
async function* diffState(state: State): AsyncGenerator<StateDiff, void, void> {
for (const t of state.tracked.keys()) {
if (!t.endsWith(".json")) {
continue;
}
const entry = state.get(t);
let fileText;
try {
fileText = await Deno.readTextFile(
path.join(state.stateRoot!, entry.path),
);
} catch {
fileText = "{}";
}
const old = JSON.parse(fileText);
const fileHash = objectHash(old);
if (fileHash !== entry.getHash()) {
const stateContent = await entry.getContent() as any;
const diff = microdiff(
old,
stateContent ?? {},
{ cyclesFix: false },
);
yield new StateDiff(entry.getId(), entry.path, diff);
}
}
}
function prettyDiff(diffs: Difference[]) {
for (const diff of diffs) {
let pathString = "";
for (const pathSegment of diff.path) {
if (typeof pathSegment === "string") {
pathString += ".";
pathString += pathSegment;
} else {
pathString += "[";
pathString += pathSegment;
pathString += "]";
}
}
if (diff.type === "REMOVE" || diff.type === "CHANGE") {
console.log(colors.red("- " + pathString + " = " + diff.oldValue));
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
console.log(colors.green("+ " + pathString + " = " + diff.value));
}
}
}
async function push(opts: GlobalOptions & { raw: boolean }) {
if (opts.raw) {
const opts2 = opts as any;
opts2.raw = undefined;
await pushRaw(opts2, undefined);
return;
}
const state = await getState(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
let error = false;
await updateStateFromRemote(workspace, state, async (filename) => {
const e = state.get(filename);
if (!e) {
throw new Error("!? State change on untracked file ?!");
}
let fileJSON;
try {
fileJSON = JSON.parse(
await Deno.readTextFile(path.join(state.stateRoot!, e.path)),
);
} catch {
fileJSON = {};
}
const file = inferTypeFromPath(e.path, fileJSON);
const eContent = (inferTypeFromPath(e.path, await e.getContent())) ?? {};
const fileHash = objectHash(file);
const eHash = objectHash(eContent);
if (fileHash !== eHash) {
console.log(
colors.red("!! Local and Remote change present. Local diff:"),
);
prettyDiff(microdiff(eContent as any, file, { cyclesFix: false }));
console.log(
colors.red(
"Consider comitting or otherwise saving your work and pulling to load any remote changes",
),
);
error = true;
return false;
}
return true;
});
if (error) {
return;
}
for (const p of state.tracked.keys()) {
if (!p.endsWith(".json")) continue;
const entry = state.get(p);
let fileJSON;
try {
fileJSON = JSON.parse(
await Deno.readTextFile(path.join(state.stateRoot!, entry.path)),
);
} catch {
fileJSON = {};
}
const file = inferTypeFromPath(entry.path, fileJSON);
const eContent =
(inferTypeFromPath(entry.path, await entry.getContent())) ?? {};
const fileHash = objectHash(file);
const eHash = objectHash(eContent);
if (fileHash !== eHash) {
const remotePath = entry.path.split(".")[0];
const type = getTypeStrFromPath(entry.path);
if (type === "script") {
// Diffing makes no sense for scripts - instead fetch parent hash & check hash again.
// If hash is still missmatched - create new script as child.
const typed = decoverto.type(ScriptFile).plainToInstance(file);
const contentPath = await findContentFile(entry.path);
const language = inferContentTypeFromFilePath(contentPath);
const content = await Deno.readTextFile(contentPath);
try {
const remote = await ScriptService.getScriptByPath({
workspace: workspace.workspaceId,
path: remotePath,
});
if (objectHash(remote) !== fileHash) {
await ScriptService.createScript({
workspace: workspace.workspaceId,
requestBody: {
content,
description: typed.description,
language,
path: remotePath,
summary: typed.summary,
is_template: typed.is_template,
kind: typed.kind,
lock: undefined,
parent_hash: remote.hash,
schema: typed.schema,
},
});
}
} catch {
// no parent hash
await ScriptService.createScript({
workspace: workspace.workspaceId,
requestBody: {
content,
description: typed.description,
language,
path: remotePath,
summary: typed.summary,
is_template: typed.is_template,
kind: typed.kind,
lock: undefined,
parent_hash: undefined,
schema: typed.schema,
},
});
}
} else {
const diff = microdiff(eContent as any, file, { cyclesFix: false });
await applyDiff(
workspace.workspaceId,
remotePath,
file,
diff,
);
}
}
}
let anyRemoteChanges = false;
await updateStateFromRemote(workspace, state, (_) => {
anyRemoteChanges = true;
return true;
});
if (anyRemoteChanges) {
console.log("New remote changes - consider pulling");
}
function applyDiff(
workspace: string,
remotePath: string,
file:
| ScriptFile
| VariableFile
| FlowFile
| ResourceFile
| ResourceTypeFile
| FolderFile,
diffs: Difference[],
) {
if (file instanceof ScriptFile) {
throw new Error(
"This code path should be unreachable - we should never generate diffs for scripts",
);
} else if (file instanceof FolderFile) {
const parts = remotePath.split("/");
if (parts[0] === "f") {
remotePath = parts[1];
} else {
remotePath = parts[0];
}
}
return file.pushDiffs(workspace, remotePath, diffs);
}
}
class StateDiff {
trackedId: TrackedId;
localPath: string;
diff: Difference[];
constructor(
trackedId: TrackedId,
localPath: string,
diff: Difference[],
) {
this.trackedId = trackedId;
this.localPath = localPath;
this.diff = diff;
}
}
async function add(opts: GlobalOptions, path: string) {
const state = await getState(opts);
// TODO: Automatically check whether this path exists either locally or on the remote
state.add(path);
if (path.endsWith(".script.json")) {
try {
const f = await findContentFile(path);
state.add(f);
} catch {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
try {
const old = await ScriptService.getScriptByPath({
workspace: workspace.workspaceId,
path: path.split(".")[0],
});
if (old.language === "python3") {
state.add(path.replace(".script.json", ".py"));
} else if (old.language === "bash") {
state.add(path.replace(".script.json", ".sh"));
} else if (old.language === "deno") {
state.add(path.replace(".script.json", ".ts"));
} else if (old.language === "go") {
state.add(path.replace(".script.json", ".go"));
} else {
throw new Error("Remote returned invalid language?! " + old.language);
}
} catch {
throw new Error(
"Could not infer script language from local or remote. Exiting.",
);
}
}
}
await state.save();
}
async function init(opts: GlobalOptions) {
const root = Deno.cwd();
try {
await Deno.mkdir(path.join(root, ".wmill"));
} catch {
console.log(
colors.red(
"! Looks like this folder is already initialized or we are missing permissions to do so. Exiting.",
),
);
return;
}
const workspace = await resolveWorkspace(opts);
const newState = new State(
new Map(),
new Map(),
new Map(),
workspace.workspaceId,
workspace.remote,
);
newState.stateRoot = root;
await newState.save();
}
async function pullRaw(
opts: GlobalOptions & { override: boolean },
dir: string,
) {
const workspace = await resolveWorkspace(opts);
const untar = await downloadTar(workspace);
if (!untar) return;
for await (const entry of untar) {
console.log(entry.fileName);
const filePath = path.resolve(dir, entry.fileName);
if (entry.type === "directory") {
await ensureDir(filePath);
continue;
}
await ensureDir(path.dirname(filePath));
if (!opts.override) {
let exists = false;
try {
const _stat = await Deno.stat(filePath);
exists = true;
} catch {
exists = false;
}
if (exists) {
if (
!(await Confirm.prompt(
"Conflict at " +
filePath +
" do you want to override the local version?",
))
) {
continue;
}
}
}
const file = await Deno.open(filePath, { write: true, create: true });
const len = await copy(entry, file);
await file.truncate(len);
file.close();
}
console.log(colors.green("Done. Wrote all files to disk."));
}
type PushRawCandidate = {
path: string;
namespaceKind: "user" | "group" | "folder";
namespaceName: string;
};
type PushRawResourceTypeCandidate = {
path: string;
};
type PushRawFolderCandidate = {
path: string;
namespaceName: string;
};
async function pushRawFindCandidateFiles(
dir: string,
): Promise<
{
normal: PushRawCandidate[];
resourceTypes: PushRawResourceTypeCandidate[];
folders: PushRawFolderCandidate[];
}
> {
dir = path.resolve(dir);
if (path.dirname(dir).startsWith(".")) {
return { normal: [], resourceTypes: [], folders: [] };
}
const normalCandidates: PushRawCandidate[] = [];
const resourceTypeCandidates: PushRawResourceTypeCandidate[] = [];
const folderCandidates: PushRawFolderCandidate[] = [];
for await (const e of Deno.readDir(dir)) {
if (e.isDirectory) {
if (e.name == "u" || e.name == "g" || e.name == "f") { // TODO: Check version for f
const newDir = dir + (dir.endsWith("/") ? "" : "/") + e.name;
for await (const e2 of Deno.readDir(newDir)) {
if (e2.isDirectory) {
if (e2.name.startsWith(".")) continue;
const namespaceName = e2.name;
const stack: string[] = [];
{
const path = newDir + "/" + namespaceName + "/";
stack.push(path);
try {
await Deno.stat(path + "folder.meta.json");
folderCandidates.push({
namespaceName,
path: path + "folder.meta.json",
});
} catch {}
}
while (stack.length > 0) {
const dir2 = stack.pop()!;
for await (const e3 of Deno.readDir(dir2)) {
if (e3.isFile) {
if (e3.name === "folder.meta.json") continue;
normalCandidates.push({
path: dir2 + e3.name,
namespaceKind: e.name == "g"
? "group"
: e.name == "u"
? "user"
: "folder",
namespaceName: namespaceName,
});
} else {
stack.push(dir2 + e3.name + "/");
}
}
}
}
}
} else {
console.log(
colors.yellow(
"Including organizational folder " + e.name + " in push!",
),
);
const { normal, resourceTypes, folders } =
await pushRawFindCandidateFiles(
path.join(dir, e.name),
);
normalCandidates.push(...normal);
resourceTypeCandidates.push(...resourceTypes);
folderCandidates.push(...folders);
}
} else {
// handle root files
if (e.name.endsWith(".resource-type.json")) {
resourceTypeCandidates.push({
path: dir + (dir.endsWith("/") ? "" : "/") + e.name,
});
}
}
}
return {
normal: normalCandidates,
folders: folderCandidates,
resourceTypes: resourceTypeCandidates,
};
}
async function pushRaw(opts: GlobalOptions, dir?: string) {
dir = dir ?? Deno.cwd();
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
console.log(colors.blue("Searching Directory..."));
const { normal, resourceTypes, folders } = await pushRawFindCandidateFiles(
dir,
);
console.log(
colors.blue(
"Found " + (normal.length + resourceTypes.length + folders.length) +
" candidates",
),
);
for (const resourceType of resourceTypes) {
const fileName = resourceType.path.substring(
resourceType.path.lastIndexOf("/") + 1,
);
const fileNameParts = fileName.split(".");
// invalid file names, like my.cool.script.script.json. Not valid.
if (fileNameParts.length != 3) {
console.log(
colors.yellow("invalid file name found at " + resourceType.path),
);
continue;
}
// filter out non-json files. Note that we filter out script contents above, so this is really an error.
if (fileNameParts.at(-1) != "json") {
console.log(colors.yellow("non-JSON file found at " + resourceType.path));
continue;
}
console.log("pushing resource type " + fileNameParts.at(-3)!);
await decoverto.type(ResourceTypeFile).rawToInstance(
await Deno.readTextFile(resourceType.path),
).push(workspace.workspaceId, fileNameParts.at(-3)!);
}
for (const folder of folders) {
await decoverto.type(FolderFile).plainToInstance(
JSON.parse(await Deno.readTextFile(folder.path)),
).push(
workspace.workspaceId,
"f/" + folder.namespaceName,
);
}
for (const candidate of normal) {
// full file name. No leading /. includes .type.json
const fileName = candidate.path.substring(
candidate.path.lastIndexOf("/") + 1,
);
// figure out just the path after ...../u|g/username|group/ (in extra dir)
const dirParts = candidate.path.split("/").filter((x) => x.length > 0);
// TODO: check version for folder
const gIndex = dirParts.findIndex((x) => x == "u" || x == "g" || x == "f");
const extraDir = dirParts.slice(gIndex + 2, -1).join("/");
// file name parts has .json (hopefully) at -1, type at -2, and the actual name at -3. Dots in names are not allowed.
const fileNameParts = fileName.split(".");
// filter out script content files
if (
fileNameParts.at(-1) == "ts" ||
fileNameParts.at(-1) == "py" ||
fileNameParts.at(-1) == "go"
) {
// probably part of a script. Silent ignore.
continue;
}
// invalid file names, like my.cool.script.script.json. Not valid.
if (fileNameParts.length != 3) {
console.log(
colors.yellow("invalid file name found at " + candidate.path),
);
continue;
}
// filter out non-json files. Note that we filter out script contents above, so this is really an error.
if (fileNameParts.at(-1) != "json") {
console.log(colors.yellow("non-JSON file found at " + candidate.path));
continue;
}
// get the type & filter it for valid ones.
const type = fileNameParts.at(-2);
if (type == "resource-type") {
console.log(
colors.yellow(
"Found resource type file at " +
candidate.path +
" this appears to be inside a path folder. Resource types are not addressed by path. Place them at the root or inside only an organizational folder. Ignoring this file!",
),
);
continue;
}
if (
type != "flow" &&
type != "resource" &&
type != "script" &&
type != "variable"
) {
console.log(
colors.yellow(
"file with invalid type " + type + " found at " + candidate.path,
),
);
continue;
}
// create the remotePath for the API
const remotePath = (candidate.namespaceKind === "group"
? "g/"
: (candidate.namespaceKind === "user" ? "u/" : "f/")) +
candidate.namespaceName +
"/" +
(extraDir.length > 0 ? extraDir + "/" : "") +
fileNameParts.at(-3);
console.log("pushing " + type + " to " + remotePath);
const typed = inferTypeFromPath(
candidate.path,
JSON.parse(await Deno.readTextFile(candidate.path)),
);
if (typed instanceof ResourceTypeFile || typed instanceof FolderFile) {
throw new Error(
"Resource Types and Folders should be filtered out at this point!",
);
} else if (typed instanceof ScriptFile) {
let contentPath: string;
try {
contentPath = await findContentFile(candidate.path);
} catch (e) {
console.log(colors.red(e.toString()));
continue;
}
await pushScript(
candidate.path,
contentPath,
workspace.workspaceId,
remotePath,
);
} else {
typed.push(workspace.workspaceId, remotePath);
}
}
console.log(colors.underline.bold.green("Successfully Pushed all files."));
}
const command = new Command()
.command("init")
.description(
"Initialize this folder as sync root for the currently selected workspace & remote.",
)
.action(init as any)
.command("add")
.description("Add a local file for tracking")
.arguments("<path:string>")
.action(add as any)
.command("pull")
.description("Pull any remote changes and apply them locally")
.option("--raw", "Pull without using state.")
.option("--raw-override", "Always override local files with remote.", {
depends: ["raw"],
})
.action(pull as any)
.command("push")
.description("Push any local changes and apply them remotely")
.option("--raw", "Push without using state.")
.action(push as any);
export default command;

View File

@@ -1,10 +0,0 @@
{
"workspace_id": "admins",
"name": "my_folder",
"display_name": "my_folder",
"owners": [],
"extra_perms": {
"u/test": true,
"u/admin@windmill.dev": false
}
}

View File

@@ -1,12 +0,0 @@
{
"summary": "Syncronize Hub Resource types with starter workspace",
"description": "Basic administrative script to sync latest resource types from hub. Recommended to run at least once. On a schedule by default.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {},
"required": [],
"type": "object"
},
"is_template": false,
"lock": []
}

View File

@@ -1,13 +0,0 @@
import wmill from "https://deno.land/x/wmill@v1.55.0/main.ts";
export async function main() {
await run(
"workspace", "add", "__automation", "starter", Deno.env.get("WM_BASE_URL") + "/", "--token", Deno.env.get("WM_TOKEN"));
await run("hub", "pull");
}
async function run(...cmd: string[]) {
console.log("Running \"" + cmd.join(' ') + "\"");
await wmill.parse(cmd);
}

View File

@@ -1,4 +1,137 @@
import { decoverto } from "./decoverto.ts";
import { FlowFile } from "./flow.ts";
import { ResourceTypeFile } from "./resource-type.ts";
import { ResourceFile } from "./resource.ts";
import { ScriptFile } from "./script.ts";
import { VariableFile } from "./variable.ts";
import { path } from "./deps.ts";
import { FolderFile } from "./folder.ts";
// TODO: Remove this & replace with a "pull" that lets the object either pull the remote version or return undefined.
// Then combine those with diffing, which then gives the new push impl
export interface Resource {
push(workspace: string, remotePath: string): Promise<void>;
}
export interface PushDiffs {
pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void>;
}
export interface DifferenceCreate {
type: "CREATE";
path: (string | number)[];
value: any;
}
export interface DifferenceRemove {
type: "REMOVE";
path: (string | number)[];
oldValue: any;
}
export interface DifferenceChange {
type: "CHANGE";
path: (string | number)[];
value: any;
oldValue: any;
}
export type Difference = DifferenceCreate | DifferenceRemove | DifferenceChange;
export function setValueByPath(
obj: any,
path: (string | number)[],
value: any,
) {
let i;
let lastObj = undefined;
for (i = 0; i < path.length - 1; i++) {
if (!obj) {
let oldNewObj;
if (typeof path[i] === "number") {
oldNewObj = [];
} else {
oldNewObj = {};
}
lastObj[path[i - 1]] = oldNewObj;
obj = oldNewObj;
}
lastObj = obj;
obj = obj[path[i]];
}
if (!obj) {
let oldNewObj;
if (typeof path[i] === "number") {
oldNewObj = [];
} else {
oldNewObj = {};
}
lastObj[path[i - 1]] = oldNewObj;
obj = oldNewObj;
}
obj[path[i]] = value;
}
export type GlobalOptions = {
workspace: string | undefined;
token: string | undefined;
};
export function inferTypeFromPath(
p: string,
obj: any,
):
| ScriptFile
| VariableFile
| FlowFile
| ResourceFile
| ResourceTypeFile
| FolderFile {
const typeEnding = getTypeStrFromPath(p);
if (typeEnding === "folder") {
return decoverto.type(FolderFile).plainToInstance(obj);
} else if (typeEnding === "script") {
return decoverto.type(ScriptFile).plainToInstance(obj);
} else if (typeEnding === "variable") {
return decoverto.type(VariableFile).plainToInstance(obj);
} else if (typeEnding === "flow") {
return decoverto.type(FlowFile).plainToInstance(obj);
} else if (typeEnding === "resource") {
return decoverto.type(ResourceFile).plainToInstance(obj);
} else if (typeEnding === "resource-type") {
return decoverto.type(ResourceTypeFile).plainToInstance(obj);
} else {
throw new Error("infer type unreachable");
}
}
export function getTypeStrFromPath(
p: string,
): "script" | "variable" | "flow" | "resource" | "resource-type" | "folder" {
const parsed = path.parse(p);
if (parsed.ext !== ".json") {
throw new Error(
"Cannot infer type of non-json file " + JSON.stringify(parsed),
);
}
if (parsed.name === "folder.meta") {
return "folder";
}
const typeEnding = parsed.name.split(".").at(-1);
if (
typeEnding === "script" || typeEnding === "variable" ||
typeEnding === "flow" || typeEnding === "resource" ||
typeEnding === "resource-type"
) {
return typeEnding;
} else {
throw new Error("Could not infer type of path " + JSON.stringify(parsed));
}
}

View File

@@ -1,7 +1,22 @@
// deno-lint-ignore-file no-explicit-any
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import { GlobalOptions } from "./types.ts";
import { colors, Command, Table, VariableService } from "./deps.ts";
import {
Difference,
GlobalOptions,
PushDiffs,
Resource,
setValueByPath,
} from "./types.ts";
import {
colors,
Command,
EditVariable,
ListableVariable,
microdiff,
Table,
VariableService,
} from "./deps.ts";
import { decoverto, model, property } from "./decoverto.ts";
async function list(opts: GlobalOptions) {
const workspace = await resolveWorkspace(opts);
@@ -26,13 +41,100 @@ async function list(opts: GlobalOptions) {
.render();
}
type VariableFile = {
@model()
export class VariableFile implements Resource, PushDiffs {
@property(() => String)
value: string;
@property(() => Boolean)
is_secret: boolean;
@property(() => String)
description: string;
@property(() => Number)
account?: number;
@property(() => Boolean)
is_oauth?: boolean;
};
constructor(value: string, is_secret: boolean, description: string) {
this.value = value;
this.is_secret = is_secret;
this.description = description;
}
async pushDiffs(
workspace: string,
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (await VariableService.existsVariable({ workspace, path: remotePath })) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing variable...`,
),
);
const changeset: EditVariable = {};
for (const diff of diffs) {
if (
diff.type !== "REMOVE" &&
(
diff.path.length !== 1 ||
!["path", "value", "is_secret", "description"].includes(
diff.path[0] as string,
)
)
) {
throw new Error("Invalid variable diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
setValueByPath(changeset, diff.path, diff.value);
} else if (diff.type === "REMOVE") {
setValueByPath(changeset, diff.path, null);
}
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
if (!hasChanges) {
console.log(colors.yellow("! Skipping empty changeset"));
return;
}
await VariableService.updateVariable({
workspace,
path: remotePath,
requestBody: changeset,
});
} else {
console.log(colors.yellow("Creating new variable..."));
await VariableService.createVariable({
workspace,
requestBody: {
path: remotePath,
description: this.description,
is_secret: this.is_secret,
value: this.value,
account: this.account,
is_oauth: this.is_oauth,
},
});
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let existing: ListableVariable | undefined;
try {
existing = await VariableService.getVariable({
workspace: workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
);
}
}
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
@@ -58,72 +160,10 @@ export async function pushVariable(
filePath: string,
remotePath: string,
) {
const data: VariableFile = JSON.parse(await Deno.readTextFile(filePath));
if (await VariableService.existsVariable({ workspace, path: remotePath })) {
const existing = await VariableService.getVariable({
workspace: workspace,
path: remotePath,
});
if (existing.is_oauth != data.is_oauth) {
console.log(
colors.red.underline.bold(
"Remote variable at " +
remotePath +
" exists & has a different oauth state. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource.",
),
);
return;
}
if (existing.account != data.account) {
console.log(
colors.red.underline.bold(
"Remote variable at " +
remotePath +
" exists & has a different account state. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource.",
),
);
return;
}
if (existing.is_secret && !data.is_secret) {
console.log(
colors.red.underline.bold(
"Remote variable at " +
remotePath +
" exists & is secret. Variables cannot be updated to be no longer secret. If you wish to do this anyways, consider deleting the remote resource.",
),
);
return;
}
const actual_secret = data.is_secret ? true : undefined;
console.log(colors.yellow("Updating existing variable..."));
await VariableService.updateVariable({
workspace,
path: remotePath,
requestBody: {
description: data.description,
is_secret: actual_secret,
path: remotePath,
value: data.value,
},
});
} else {
console.log(colors.yellow("Creating new variable..."));
await VariableService.createVariable({
workspace,
requestBody: {
path: remotePath,
description: data.description,
is_secret: data.is_secret,
value: data.value,
account: data.account,
is_oauth: data.is_oauth,
},
});
}
const data = decoverto.type(VariableFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, remotePath);
}
const command = new Command()

View File

@@ -11,14 +11,31 @@ import {
Table,
WorkspaceService,
} from "./deps.ts";
import { requireLogin } from "./context.ts";
import { decoverto, model, property } from "./decoverto.ts";
export type Workspace = {
@model()
export class Workspace {
@property(() => String)
remote: string;
@property(() => String)
workspaceId: string;
@property(() => String)
name: string;
@property(() => String)
token: string;
};
constructor(
remote: string,
workspaceId: string,
name: string,
token: string,
) {
this.remote = remote;
this.workspaceId = workspaceId;
this.name = name;
this.token = token;
}
}
function makeWorkspaceStream(
readable: ReadableStream<Uint8Array>,
@@ -33,7 +50,9 @@ function makeWorkspaceStream(
if (line.length <= 2) {
return;
}
controller.enqueue(JSON.parse(line) as Workspace);
const workspace = decoverto.type(Workspace).rawToInstance(line);
workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases!
controller.enqueue(workspace);
} catch {
/* ignore */
}
@@ -42,14 +61,17 @@ function makeWorkspaceStream(
);
}
async function allWorkspaces(): Promise<Workspace[]> {
try {
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
write: false,
read: true,
});
const workspaceStream = makeWorkspaceStream(file.readable);
export async function getWorkspaceStream() {
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
write: false,
read: true,
});
return makeWorkspaceStream(file.readable);
}
export async function allWorkspaces(): Promise<Workspace[]> {
try {
const workspaceStream = await getWorkspaceStream();
const workspaces: Workspace[] = [];
for await (const workspace of workspaceStream) {
workspaces.push(workspace);
@@ -87,8 +109,7 @@ export async function getActiveWorkspace(
export async function getWorkspaceByName(
workspaceName: string,
): Promise<Workspace | undefined> {
const file = await Deno.open((await getRootStore()) + "remotes.ndjson");
const workspaceStream = makeWorkspaceStream(file.readable);
const workspaceStream = await getWorkspaceStream();
for await (const workspace of workspaceStream) {
if (workspace.name === workspaceName) {
return workspace;
@@ -185,6 +206,7 @@ export async function add(
remote = new URL(await Input.prompt("Enter the Remote URL")).toString();
}
}
remote = new URL(remote).toString(); // add trailing slash in all cases!
let token = await tryGetLoginInfo(opts);
while (!token) {
@@ -192,7 +214,10 @@ export async function add(
}
if (opts.create) {
setClient(token, remote.substring(0, remote.length - 1));
setClient(
token,
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote,
);
if (
!await WorkspaceService.existsWorkspace({
@@ -224,6 +249,7 @@ export async function add(
}
export async function addWorkspace(workspace: Workspace) {
workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases!
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
append: true,
write: true,
@@ -234,7 +260,7 @@ export async function addWorkspace(workspace: Workspace) {
file.close();
}
async function remove(_opts: GlobalOptions, name: string) {
export async function removeWorkspace(name: string) {
const orgWorkspaces = await allWorkspaces();
await Deno.writeTextFile(
(await getRootStore()) + "remotes.ndjson",
@@ -243,6 +269,10 @@ async function remove(_opts: GlobalOptions, name: string) {
.map((x) => JSON.stringify(x))
.join("\n"),
);
}
async function remove(_opts: GlobalOptions, name: string) {
await removeWorkspace(name);
console.log(colors.green.underline("Succesfully removed workspace!"));
}

View File

@@ -7,6 +7,12 @@ export {
UserService, WorkspaceService
} from './windmill-api/index.ts'
// @ts-ignore: Otherwise BigInt is not supported for export
BigInt.prototype.toJSON = function () {
return this.toString();
};
export { pgSql, pgClient } from './pg.ts'
export { mySql, mysqlClient } from './mysql.ts'
@@ -72,7 +78,7 @@ export async function setResource(value: any, path?: string, initializeToTypeIfN
path = path ?? getStatePath()
const workspace = getWorkspace()
if (await ResourceService.existsResource({ workspace, path })) {
await ResourceService.updateResourceValue({ workspace, path, requestBody: value })
await ResourceService.updateResourceValue({ workspace, path, requestBody: { value } })
} else if (initializeToTypeIfNotExist) {
await ResourceService.createResource({ workspace, requestBody: { path, value, resource_type: initializeToTypeIfNotExist } })
} else {

1
depot.json Normal file
View File

@@ -0,0 +1 @@
{ "id": "00zvxx9420" }

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