Compare commits
339 Commits
rf/fixGrap
...
tutorials
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76426ba83 | ||
|
|
1cb2a177c6 | ||
|
|
db87577a1b | ||
|
|
bc440f8d41 | ||
|
|
c219eb0ee9 | ||
|
|
1d5c194f09 | ||
|
|
7a9d230459 | ||
|
|
4d5e2499cf | ||
|
|
686275fd46 | ||
|
|
99399f4f77 | ||
|
|
6e09194313 | ||
|
|
7c825c212d | ||
|
|
bfe5b56c99 | ||
|
|
480fd781b6 | ||
|
|
4f2079f624 | ||
|
|
43c45d930c | ||
|
|
8d5c5b88a3 | ||
|
|
cc8bedd0c7 | ||
|
|
74c3d6443c | ||
|
|
c7be313210 | ||
|
|
ae53bafaf6 | ||
|
|
2ea15d5035 | ||
|
|
0f187d66dd | ||
|
|
4453690521 | ||
|
|
6691b19b24 | ||
|
|
2f9ccff65c | ||
|
|
09db6fd867 | ||
|
|
fd52740d5d | ||
|
|
6b0fb75d23 | ||
|
|
b1a45b1e70 | ||
|
|
b2de531a46 | ||
|
|
a4adcb5192 | ||
|
|
0c2cf92dd3 | ||
|
|
e6344dac6d | ||
|
|
8fb2454e83 | ||
|
|
3b6ae0cc49 | ||
|
|
96ff2eebc1 | ||
|
|
ed29d51c36 | ||
|
|
88e537ad1f | ||
|
|
b854ee3439 | ||
|
|
0a5e181a3a | ||
|
|
8cc59225d8 | ||
|
|
9c41346dde | ||
|
|
41a398f50e | ||
|
|
3436061ad4 | ||
|
|
569b5d2516 | ||
|
|
a08cdd7b86 | ||
|
|
719d475262 | ||
|
|
5b3e1183e5 | ||
|
|
7ed301b186 | ||
|
|
46b6e4371b | ||
|
|
e0d3465b07 | ||
|
|
7f8fe8dc17 | ||
|
|
24f58efd99 | ||
|
|
67d8009dcf | ||
|
|
95ccc9edf8 | ||
|
|
9e4d90ad37 | ||
|
|
c638897fdc | ||
|
|
71305e5154 | ||
|
|
9e9f8efb8e | ||
|
|
3e5d09ef0b | ||
|
|
614fb5022a | ||
|
|
0beadfd1ac | ||
|
|
25580c1272 | ||
|
|
2557e136bd | ||
|
|
200cb69d82 | ||
|
|
9ee261fe1a | ||
|
|
8e563a42f5 | ||
|
|
a999eb2112 | ||
|
|
e5dbe7076c | ||
|
|
2ac51b0af0 | ||
|
|
f3232062c3 | ||
|
|
b11a5a2df6 | ||
|
|
e2c4545240 | ||
|
|
70dd6f759c | ||
|
|
dcfb29fb80 | ||
|
|
94f1aadef2 | ||
|
|
58300eb6ac | ||
|
|
304dea4b74 | ||
|
|
f4fe71e074 | ||
|
|
fd4e18f62f | ||
|
|
e428662481 | ||
|
|
b796aeef7a | ||
|
|
55eb48c553 | ||
|
|
a43139fe53 | ||
|
|
c4463bb029 | ||
|
|
cc6eaaf473 | ||
|
|
ed25d9f186 | ||
|
|
35ea2b27b1 | ||
|
|
2c1e3b3372 | ||
|
|
4101d587de | ||
|
|
e6ff3ab6cc | ||
|
|
8fc6c39129 | ||
|
|
fcb5cf4d41 | ||
|
|
2679386bf8 | ||
|
|
580388ce19 | ||
|
|
4e6e66d7b1 | ||
|
|
f4d79ee263 | ||
|
|
38fb3450c8 | ||
|
|
94b20d2f5e | ||
|
|
1753cb7da6 | ||
|
|
2a75cd250e | ||
|
|
29f3fe2663 | ||
|
|
4c913dc4b6 | ||
|
|
5c40ff4290 | ||
|
|
2bbe112444 | ||
|
|
90a12f6131 | ||
|
|
f3f95fa865 | ||
|
|
26784464a4 | ||
|
|
c96e2351d9 | ||
|
|
ddb4916a2e | ||
|
|
1bb5ed9ae0 | ||
|
|
b5b32f00b3 | ||
|
|
c06311faf8 | ||
|
|
8a639b6e7d | ||
|
|
05f568fb8c | ||
|
|
e515c70e71 | ||
|
|
6adc875610 | ||
|
|
8a0d1158c4 | ||
|
|
ea2ebfa92e | ||
|
|
ba856be10d | ||
|
|
333b873ee9 | ||
|
|
2785b05064 | ||
|
|
a67f10eeb6 | ||
|
|
287b2db22f | ||
|
|
a4e4d188ad | ||
|
|
2244e83b9d | ||
|
|
42d1cd6456 | ||
|
|
4b64e75bd1 | ||
|
|
51a7eaaeb0 | ||
|
|
8589b70ccf | ||
|
|
0bf6f23c9e | ||
|
|
e56869092a | ||
|
|
6b8758f4a5 | ||
|
|
fbc929ba1b | ||
|
|
97602ac6db | ||
|
|
8ee9d67f4f | ||
|
|
4bf6e753f1 | ||
|
|
70eab303bd | ||
|
|
c051ffeb42 | ||
|
|
ebb68e5320 | ||
|
|
04a076f1db | ||
|
|
ebd2e0323e | ||
|
|
cd25570003 | ||
|
|
e95f8ef6bf | ||
|
|
c3d1c8ac39 | ||
|
|
d38aff2fe2 | ||
|
|
95851ea486 | ||
|
|
b690d801d4 | ||
|
|
104e4ac5e7 | ||
|
|
e87f4fc44b | ||
|
|
e1f686d850 | ||
|
|
7da7dac3ac | ||
|
|
c2e5afd4e0 | ||
|
|
ad9c386f41 | ||
|
|
a4e3f98b7d | ||
|
|
dd28308c3c | ||
|
|
833c2655ea | ||
|
|
a8295d0b5a | ||
|
|
897e2f6b53 | ||
|
|
5bb77edf45 | ||
|
|
8ddcf4d9c1 | ||
|
|
33ebe2da8e | ||
|
|
b3ee747014 | ||
|
|
fa105b4cae | ||
|
|
483407cdf0 | ||
|
|
008c30fcaa | ||
|
|
3387bb0d83 | ||
|
|
e08e7e4ae6 | ||
|
|
ea1b2c29b9 | ||
|
|
4ad6fbefd3 | ||
|
|
397ecd64d4 | ||
|
|
dd7e8c742c | ||
|
|
2f78132e08 | ||
|
|
0041411c06 | ||
|
|
54955b710c | ||
|
|
1eb5a0d1d3 | ||
|
|
ddda14c52b | ||
|
|
5123c9365c | ||
|
|
834e7b1d1c | ||
|
|
6e9a5b026e | ||
|
|
7ad8879b09 | ||
|
|
27cac3ffe6 | ||
|
|
705703a5e2 | ||
|
|
fac31c6628 | ||
|
|
90c0e140a1 | ||
|
|
d543650b31 | ||
|
|
089a6b6ae5 | ||
|
|
857ee5f318 | ||
|
|
6ad876ebb4 | ||
|
|
ab4137640e | ||
|
|
2bd8fabcf7 | ||
|
|
3b7160e84a | ||
|
|
40c12e6139 | ||
|
|
6044e3b6ef | ||
|
|
18ff5c7cef | ||
|
|
e54dc3ff97 | ||
|
|
4d5aae69c8 | ||
|
|
ec57c5977f | ||
|
|
df1b724626 | ||
|
|
268dfbf831 | ||
|
|
969e89f8bb | ||
|
|
5997503961 | ||
|
|
3fa24adad0 | ||
|
|
7471be1d81 | ||
|
|
d64e1c116a | ||
|
|
9267b1fb90 | ||
|
|
6528a68668 | ||
|
|
4fd4d17a0d | ||
|
|
0548803ab7 | ||
|
|
0085b46c1e | ||
|
|
81ffd49bef | ||
|
|
dbc59e9521 | ||
|
|
121b3e9060 | ||
|
|
70dfc8b8d0 | ||
|
|
ca3572a2a1 | ||
|
|
32c3c591d7 | ||
|
|
1f4bc55e5c | ||
|
|
a00ff45ccf | ||
|
|
0160ce978d | ||
|
|
867c00047a | ||
|
|
e31d2ae27f | ||
|
|
441f087d42 | ||
|
|
4671558e6b | ||
|
|
08519f4099 | ||
|
|
2727699d91 | ||
|
|
0c43b68b23 | ||
|
|
c280f6e798 | ||
|
|
e81f7bd723 | ||
|
|
2213500210 | ||
|
|
7558fb83d2 | ||
|
|
3d7a5a4520 | ||
|
|
be6f052ba4 | ||
|
|
4f1bcbb1c3 | ||
|
|
a4b773af29 | ||
|
|
61e6e1a4c5 | ||
|
|
cf7dc3c01a | ||
|
|
41c8ea92fe | ||
|
|
b6b0880f2f | ||
|
|
d4b6d69126 | ||
|
|
75edeab35e | ||
|
|
71d6dad37c | ||
|
|
7f00e1c1a8 | ||
|
|
a39f8e2123 | ||
|
|
2de660fef6 | ||
|
|
dc1be9cf55 | ||
|
|
91e1781dc1 | ||
|
|
e4791c2b7e | ||
|
|
c33e79e0b8 | ||
|
|
5d109b3cd4 | ||
|
|
8074b26bfb | ||
|
|
98c1806369 | ||
|
|
7120d6b35b | ||
|
|
772bb602b0 | ||
|
|
5c8789b730 | ||
|
|
c7cd8e22d0 | ||
|
|
11c2c2704d | ||
|
|
06a8fcf666 | ||
|
|
8445697e31 | ||
|
|
e0b12f88d5 | ||
|
|
a2fbc57025 | ||
|
|
3e5950a396 | ||
|
|
c0b87cc7d7 | ||
|
|
81f64a4028 | ||
|
|
6eecae6857 | ||
|
|
03eb1444c4 | ||
|
|
7f68ae888c | ||
|
|
1db407d983 | ||
|
|
9f6bffab72 | ||
|
|
b835c58427 | ||
|
|
d2eb7a40c5 | ||
|
|
f446ca14f5 | ||
|
|
5b7ce39496 | ||
|
|
2789dc2e5f | ||
|
|
6b70dbcc61 | ||
|
|
3474cd0687 | ||
|
|
dabceae2ea | ||
|
|
64e5bcf4b6 | ||
|
|
9767980ca0 | ||
|
|
100943443b | ||
|
|
77a7b8a539 | ||
|
|
69001bd61a | ||
|
|
13b1055a5f | ||
|
|
e5c4e2a754 | ||
|
|
5c0b0529df | ||
|
|
e825bc94dc | ||
|
|
4d558640a9 | ||
|
|
996efa1ff2 | ||
|
|
3f2754b330 | ||
|
|
4aaa5d8fb8 | ||
|
|
c5c979b7d7 | ||
|
|
a574270bc2 | ||
|
|
c8f0e23eae | ||
|
|
42b94947c4 | ||
|
|
b03b3be154 | ||
|
|
3f8916cbc2 | ||
|
|
ac991dddbc | ||
|
|
083a304645 | ||
|
|
91491055fa | ||
|
|
ae440203f0 | ||
|
|
ab432d628a | ||
|
|
e1b9247e11 | ||
|
|
07c756f460 | ||
|
|
2ef6af4546 | ||
|
|
a939771059 | ||
|
|
8dc467b87a | ||
|
|
2ece1eb475 | ||
|
|
7a4da3907f | ||
|
|
b9d6e67791 | ||
|
|
f584062f13 | ||
|
|
265fbc5835 | ||
|
|
2e7e57b62d | ||
|
|
d17eeeecdc | ||
|
|
21c2007ebd | ||
|
|
90668902f5 | ||
|
|
784aac9d1b | ||
|
|
d4207db880 | ||
|
|
4ac9484305 | ||
|
|
0a8f177e02 | ||
|
|
cfa1e6f1e8 | ||
|
|
be526b2f23 | ||
|
|
8bc97e0041 | ||
|
|
b9ac60f8bb | ||
|
|
c0a8545704 | ||
|
|
cdd16195ae | ||
|
|
406cba4e73 | ||
|
|
8d6a8386be | ||
|
|
1a626980df | ||
|
|
23007f7a71 | ||
|
|
9f5500c196 | ||
|
|
a82a2efa6a | ||
|
|
3305481d5d | ||
|
|
42691bc1bd | ||
|
|
99568eaa47 | ||
|
|
68500b12b2 | ||
|
|
f171cd8b7c | ||
|
|
0ca431b6cb | ||
|
|
cb9c0846ac | ||
|
|
bc8d1a375e |
2
.github/workflows/backend-test.yml
vendored
2
.github/workflows/backend-test.yml
vendored
@@ -40,4 +40,4 @@ jobs:
|
||||
backend -> target
|
||||
- name: cargo test
|
||||
timeout-minutes: 10
|
||||
run: mkdir frontend/build && cd backend && touch windmill-api/openapi-deref.yaml && 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 DISABLE_NSJAIL=false cargo test --all -- --nocapture
|
||||
|
||||
1
.github/workflows/change-versions.yml
vendored
1
.github/workflows/change-versions.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
||||
container: node:18
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: git config --system --add safe.directory /__w/windmill/windmill
|
||||
- name: Change versions
|
||||
run: ./.github/change-versions.sh "$(cat version.txt)"
|
||||
- uses: actions-rs/toolchain@v1
|
||||
|
||||
20
.github/workflows/deploy_to_windmill.yml
vendored
20
.github/workflows/deploy_to_windmill.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Deploy to windmill.dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "community/**"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Deploy to windmill.dev
|
||||
uses: windmill-labs/windmill-gh-action-deploy@v2.0.0
|
||||
with:
|
||||
dry_run: false
|
||||
input_dir: community
|
||||
windmill_workspace: starter
|
||||
windmill_token: ${{ secrets.WINDMILL_API_TOKEN }}
|
||||
68
.github/workflows/docker-image.yml
vendored
68
.github/workflows/docker-image.yml
vendored
@@ -109,38 +109,38 @@ jobs:
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
|
||||
playwright:
|
||||
runs-on: [self-hosted, new]
|
||||
needs: [build]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: windmill
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: changeme
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
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.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 && export BASE_URL=http://localhost:8000 && npm run test
|
||||
- name: "Clean up"
|
||||
run: docker kill ${{ steps.docker-container.outputs.id }}
|
||||
if: always()
|
||||
# disabled until we make it 100% reliable and add more meaningful tests
|
||||
# playwright:
|
||||
# runs-on: [self-hosted, new]
|
||||
# needs: [build]
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres
|
||||
# env:
|
||||
# POSTGRES_DB: windmill
|
||||
# POSTGRES_USER: admin
|
||||
# POSTGRES_PASSWORD: changeme
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# 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.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 && export BASE_URL=http://localhost:8000 && npm run test
|
||||
# - name: "Clean up"
|
||||
# run: docker kill ${{ steps.docker-container.outputs.id }}
|
||||
# if: always()
|
||||
|
||||
|
||||
publish_privately_heavy:
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push privately
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
context: .
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Build and push privately
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
context: .
|
||||
|
||||
2
.github/workflows/pypi_on_release.yml
vendored
2
.github/workflows/pypi_on_release.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Build and push publicly
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: "{{defaultContext}}:lsp"
|
||||
push: true
|
||||
|
||||
@@ -25,12 +25,12 @@ jobs:
|
||||
run: echo "UUID_TAG_APP=$(uuidgen)" >> $GITHUB_ENV
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
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
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
push: true
|
||||
context: ./
|
||||
320
CHANGELOG.md
320
CHANGELOG.md
@@ -1,6 +1,326 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## [1.74.2](https://github.com/windmill-labs/windmill/compare/v1.74.1...v1.74.2) (2023-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** fix splitpanes navigation ([#1276](https://github.com/windmill-labs/windmill/issues/1276)) ([8d5c5b8](https://github.com/windmill-labs/windmill/commit/8d5c5b88a35d7a3bad1d8ddf2d940026825241eb))
|
||||
|
||||
## [1.74.1](https://github.com/windmill-labs/windmill/compare/v1.74.0...v1.74.1) (2023-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **apps:** proper reactivity for non rendered static components ([ae53baf](https://github.com/windmill-labs/windmill/commit/ae53bafaf6777f928113f84b2c6ed6a2ed341844))
|
||||
* **ci:** make windmill compile again by pinning swc deps ([2ea15d5](https://github.com/windmill-labs/windmill/commit/2ea15d5035e5e15473968db3c0501a4dddff5cd0))
|
||||
|
||||
## [1.74.0](https://github.com/windmill-labs/windmill/compare/v1.73.1...v1.74.0) (2023-03-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add delete by path for scripts ([0c2cf92](https://github.com/windmill-labs/windmill/commit/0c2cf92dd3df9610e649f15e23921a4ca0d94e6a))
|
||||
* **frontend:** Add color picker input to app ([#1270](https://github.com/windmill-labs/windmill/issues/1270)) ([88e537a](https://github.com/windmill-labs/windmill/commit/88e537ad1fb4c207f38fbe951c82106bef6491a3))
|
||||
* **frontend:** add expand ([#1268](https://github.com/windmill-labs/windmill/issues/1268)) ([b854ee3](https://github.com/windmill-labs/windmill/commit/b854ee34393534bde104e2e6f606108fd66d38dc))
|
||||
* **frontend:** add hash to ctx in apps ([b1a45b1](https://github.com/windmill-labs/windmill/commit/b1a45b1e708aa6f19f8be9c949507083e044f2d8))
|
||||
* **frontend:** Add key navigation in app editor ([#1273](https://github.com/windmill-labs/windmill/issues/1273)) ([6b0fb75](https://github.com/windmill-labs/windmill/commit/6b0fb75d23e2151c88b07814139d203c1bd0578d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** improve visibility of the active workspace ([e6344da](https://github.com/windmill-labs/windmill/commit/e6344dac6d1be04b46231fa8ef8579fd12ca8f37))
|
||||
* **frontend:** add confirmation modal to delete script/flow/app ([a4adcb5](https://github.com/windmill-labs/windmill/commit/a4adcb5192c11f7bf47a0d259825e474779378d7))
|
||||
* **frontend:** Clean up app editor ([#1267](https://github.com/windmill-labs/windmill/issues/1267)) ([0a5e181](https://github.com/windmill-labs/windmill/commit/0a5e181a3aa966fb8211bee0d9174fc16353b31f))
|
||||
* **frontend:** Minor changes ([#1272](https://github.com/windmill-labs/windmill/issues/1272)) ([3b6ae0c](https://github.com/windmill-labs/windmill/commit/3b6ae0cc49461b858d9cfff79eae9a7569465235))
|
||||
* **frontend:** simplify input bindings ([b2de531](https://github.com/windmill-labs/windmill/commit/b2de531a46e4b120d7106d361b727746bec516dd))
|
||||
|
||||
## [1.73.1](https://github.com/windmill-labs/windmill/compare/v1.73.0...v1.73.1) (2023-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** load flow is not initialized ([719d475](https://github.com/windmill-labs/windmill/commit/719d4752621d462b1cfaa0d27930fba7586be779))
|
||||
|
||||
## [1.73.0](https://github.com/windmill-labs/windmill/compare/v1.72.0...v1.73.0) (2023-03-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add a way to automatically resize ([#1259](https://github.com/windmill-labs/windmill/issues/1259)) ([24f58ef](https://github.com/windmill-labs/windmill/commit/24f58efd9994a2201c1b1d9bbfb11734c57068e3))
|
||||
* **frontend:** add ability to move nodes ([614fb50](https://github.com/windmill-labs/windmill/commit/614fb5022aa7d5428fb96b7ee3a20794edd1e9d3))
|
||||
* **frontend:** Add app PDF viewer ([#1254](https://github.com/windmill-labs/windmill/issues/1254)) ([3e5d09e](https://github.com/windmill-labs/windmill/commit/3e5d09ef0b5619186bee5ec6d442cbfd12a6e8d5))
|
||||
* **frontend:** add fork/save buttons + consistent styling for slider/range ([9e9f8ef](https://github.com/windmill-labs/windmill/commit/9e9f8efb8ee389ea75e99b67ef720756959ca737))
|
||||
* **frontend:** add history to flows and apps ([9e4d90a](https://github.com/windmill-labs/windmill/commit/9e4d90ad37a57ff1f515eea0c82cf603649e915d))
|
||||
* **frontend:** Fix object viewer style ([#1255](https://github.com/windmill-labs/windmill/issues/1255)) ([94f1aad](https://github.com/windmill-labs/windmill/commit/94f1aadef2b09ac1962478f11b27cc708b8328f1))
|
||||
* **frontend:** refactor entire flow builder UX ([2ac51b0](https://github.com/windmill-labs/windmill/commit/2ac51b0af08bdef7ce3c7e874e9983b9fc00478a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** arginput + apppreview fixes ([e2c4545](https://github.com/windmill-labs/windmill/commit/e2c45452401022b00285b21551ffaf35a114be33))
|
||||
* **frontend:** fix app map reactivity ([#1260](https://github.com/windmill-labs/windmill/issues/1260)) ([2557e13](https://github.com/windmill-labs/windmill/commit/2557e136bd0df1a023819b7d9b2235e30d7140b6))
|
||||
* **frontend:** fix branch deletion ([#1261](https://github.com/windmill-labs/windmill/issues/1261)) ([a999eb2](https://github.com/windmill-labs/windmill/commit/a999eb21121a7c0010621448324e0c77caf2b3f6))
|
||||
* **frontend:** Side menu z-index issue ([#1265](https://github.com/windmill-labs/windmill/issues/1265)) ([c638897](https://github.com/windmill-labs/windmill/commit/c638897fdcd58f55b0929f91641b21a6f9d25ead))
|
||||
|
||||
## [1.72.0](https://github.com/windmill-labs/windmill/compare/v1.71.0...v1.72.0) (2023-03-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend:** get_result_by_id do a downward pass to find node at any depth ([#1249](https://github.com/windmill-labs/windmill/issues/1249)) ([4c913dc](https://github.com/windmill-labs/windmill/commit/4c913dc4b6be03571a015c97a13829adffb61479))
|
||||
* **frontend:** Add app map component ([#1251](https://github.com/windmill-labs/windmill/issues/1251)) ([ed25d9f](https://github.com/windmill-labs/windmill/commit/ed25d9f186d9925f75404cb193a025d8a41c4540))
|
||||
* **frontend:** app splitpanes ([#1248](https://github.com/windmill-labs/windmill/issues/1248)) ([f4d79ee](https://github.com/windmill-labs/windmill/commit/f4d79ee2633e6cdab0fa2410108b31cfa77e10da))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** improve result retrieval ([c4463bb](https://github.com/windmill-labs/windmill/commit/c4463bb029907f3c8d77abb194f872aae7876bf6))
|
||||
* **backend:** incorrect get_result_by_id for list_result job ([2a75cd2](https://github.com/windmill-labs/windmill/commit/2a75cd250ea5e01849fc8bbb69bf44f147d0acb8))
|
||||
* **cli:** fix workspace option + run script/flow + whoami ([35ea2b2](https://github.com/windmill-labs/windmill/commit/35ea2b27b12159c68c8507ec1f8686028c975387))
|
||||
* **frontend:** background script not showing inputs ([55eb48c](https://github.com/windmill-labs/windmill/commit/55eb48c55332431304cedbf3bcbbbcff61ec3645))
|
||||
* **frontend:** fix table bindings ([2679386](https://github.com/windmill-labs/windmill/commit/2679386bf87a56352269911bd89e52df5ee9f314))
|
||||
* **frontend:** rework app reactivity ([94b20d2](https://github.com/windmill-labs/windmill/commit/94b20d2f5e3b551974c57ea82b6e3dc16e97b9b8))
|
||||
* **frontend:** rework app reactivity ([1753cb7](https://github.com/windmill-labs/windmill/commit/1753cb7da658f47be974c15da82c71a8e19309a6))
|
||||
|
||||
## [1.71.0](https://github.com/windmill-labs/windmill/compare/v1.70.1...v1.71.0) (2023-02-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend:** use counter for sleep/execution/pull durations ([e568690](https://github.com/windmill-labs/windmill/commit/e56869092a03fec4703ddd9ef65c89edb8122962))
|
||||
* **cli:** add autocompletions ([287b2db](https://github.com/windmill-labs/windmill/commit/287b2db22f7b56e90bcd0c4727c00096695c2e0d))
|
||||
* **frontend:** App drawer ([#1246](https://github.com/windmill-labs/windmill/issues/1246)) ([8a0d115](https://github.com/windmill-labs/windmill/commit/8a0d1158c4d7e970cb91e1adf4838e5efdbb39ff))
|
||||
* **frontend:** drawer for editing workspace scripts in flows ([6adc875](https://github.com/windmill-labs/windmill/commit/6adc87561070d8aceaba1838008cd7e6be2e2660))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** Add more app custom css ([#1229](https://github.com/windmill-labs/windmill/issues/1229)) ([a4e4d18](https://github.com/windmill-labs/windmill/commit/a4e4d188ad10443dd0b7f104389594efc768dc59))
|
||||
* **frontend:** Add more app custom css ([#1247](https://github.com/windmill-labs/windmill/issues/1247)) ([1bb5ed9](https://github.com/windmill-labs/windmill/commit/1bb5ed9ae01fd7998b06833b6222e5dd5d774d35))
|
||||
* **frontend:** display currently selected filter even if not in list ([42d1cd6](https://github.com/windmill-labs/windmill/commit/42d1cd6456620ba917c560c87d736dc93634adff))
|
||||
* **frontend:** Fix deeply nested move ([#1245](https://github.com/windmill-labs/windmill/issues/1245)) ([a67f10e](https://github.com/windmill-labs/windmill/commit/a67f10eeb6fdb44bbb3a510badcc5ad0ae187a2b))
|
||||
* **frontend:** invisible subgrids have h-0 + app policies fix ([2244e83](https://github.com/windmill-labs/windmill/commit/2244e83b9da803a4cf46ab0825d7cb6cb0e24872))
|
||||
|
||||
## [1.70.1](https://github.com/windmill-labs/windmill/compare/v1.70.0...v1.70.1) (2023-02-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** make cli resilient to systems without openable browsers ([c051ffe](https://github.com/windmill-labs/windmill/commit/c051ffeb42c1cff609f93da7745036ea722e17d4))
|
||||
* **frontend:** Disable move in nested subgrid ([#1238](https://github.com/windmill-labs/windmill/issues/1238)) ([70eab30](https://github.com/windmill-labs/windmill/commit/70eab303bd45111ae198d9b710bfd6f9f59e53b0))
|
||||
* **frontend:** Fix inline scripts list ([#1240](https://github.com/windmill-labs/windmill/issues/1240)) ([97602ac](https://github.com/windmill-labs/windmill/commit/97602ac6db1404d36d160a431ffcea6c0f567a48))
|
||||
* **frontend:** Fix subgrid lock ([#1232](https://github.com/windmill-labs/windmill/issues/1232)) ([8ee9d67](https://github.com/windmill-labs/windmill/commit/8ee9d67f4faa91446338b41c664ef91913eb8b81))
|
||||
|
||||
## [1.70.1](https://github.com/windmill-labs/windmill/compare/v1.70.0...v1.70.1) (2023-02-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** make cli resilient to systems without openable browsers ([c051ffe](https://github.com/windmill-labs/windmill/commit/c051ffeb42c1cff609f93da7745036ea722e17d4))
|
||||
* **frontend:** Disable move in nested subgrid ([#1238](https://github.com/windmill-labs/windmill/issues/1238)) ([70eab30](https://github.com/windmill-labs/windmill/commit/70eab303bd45111ae198d9b710bfd6f9f59e53b0))
|
||||
* **frontend:** Fix subgrid lock ([#1232](https://github.com/windmill-labs/windmill/issues/1232)) ([8ee9d67](https://github.com/windmill-labs/windmill/commit/8ee9d67f4faa91446338b41c664ef91913eb8b81))
|
||||
|
||||
## [1.70.0](https://github.com/windmill-labs/windmill/compare/v1.69.3...v1.70.0) (2023-02-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **apps:** add ag grid ([b690d80](https://github.com/windmill-labs/windmill/commit/b690d801d4aa5695ee558e81d1ed114074dfcb83))
|
||||
* **frontend:** move to other grid ([#1230](https://github.com/windmill-labs/windmill/issues/1230)) ([104e4ac](https://github.com/windmill-labs/windmill/commit/104e4ac5e790c30e6fb6b27726776693038d4f19))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* app setup and sync now uses 1.69.3 ([d38aff2](https://github.com/windmill-labs/windmill/commit/d38aff2fe228f23eb18c3991392928c064e6aca2))
|
||||
* **frontend:** Fix duplication ([#1237](https://github.com/windmill-labs/windmill/issues/1237)) ([e87f4fc](https://github.com/windmill-labs/windmill/commit/e87f4fc44b847a573f5acafc0348fbcbfcb2258f))
|
||||
* **frontend:** fix graph viewer id assignment ([e1f686d](https://github.com/windmill-labs/windmill/commit/e1f686d8508cfc1f73c43be08facc44217ca8de0))
|
||||
|
||||
## [1.69.3](https://github.com/windmill-labs/windmill/compare/v1.69.2...v1.69.3) (2023-02-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deno:** fix denoify buffer handling ([c2e5afd](https://github.com/windmill-labs/windmill/commit/c2e5afd4e07fb63375832f308da8c744616ee188))
|
||||
|
||||
## [1.69.2](https://github.com/windmill-labs/windmill/compare/v1.69.1...v1.69.2) (2023-02-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **app:** fix all nested behavior ([dd28308](https://github.com/windmill-labs/windmill/commit/dd28308c3cf1877ba3f19dcd2bd20bf1c7896a99))
|
||||
* **frontend:** delete grid item ([008c30f](https://github.com/windmill-labs/windmill/commit/008c30fcaad64af512407f9889a9881fafac0868))
|
||||
* **frontend:** duplicate ([483407c](https://github.com/windmill-labs/windmill/commit/483407cdf0e1ed61de180a904934e950fed4adc3))
|
||||
* **frontend:** Fix findGridItem ([a8295d0](https://github.com/windmill-labs/windmill/commit/a8295d0b5acd08cec42b7939d907df5c25132644))
|
||||
* **frontend:** Fix findGridItem ([5bb77ed](https://github.com/windmill-labs/windmill/commit/5bb77edf45740a75e969b1bef31580271c9d5505))
|
||||
* **frontend:** Fix next id ([8ddcf4d](https://github.com/windmill-labs/windmill/commit/8ddcf4d9c1a8d6dd20ee241a3f308811c49e58f1))
|
||||
* **frontend:** gridtab ([fa105b4](https://github.com/windmill-labs/windmill/commit/fa105b4caeaa2d0e9704a48f6caf8d846839c23e))
|
||||
* **frontend:** rewrote utils ([ea1b2c2](https://github.com/windmill-labs/windmill/commit/ea1b2c29b95282df347ef9c5973917fa3880e843))
|
||||
* **frontend:** wip ([33ebe2d](https://github.com/windmill-labs/windmill/commit/33ebe2da8e81476be62a2567d5012573a8a010b6))
|
||||
|
||||
## [1.69.1](https://github.com/windmill-labs/windmill/compare/v1.69.0...v1.69.1) (2023-02-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deno:** remove mysql support waiting for deno fix ([dd7e8c7](https://github.com/windmill-labs/windmill/commit/dd7e8c742c83f6a1d13e4343ca626c0b5efc06fb))
|
||||
* **deno:** remove mysql support waiting for deno fix ([2f78132](https://github.com/windmill-labs/windmill/commit/2f78132e081bdf3d7468e022f0e981ebfa52cfb3))
|
||||
* **frontend:** containers and tab fixes v1 ([27cac3f](https://github.com/windmill-labs/windmill/commit/27cac3ffe69c4dac160e9e55ffd1eb8ea348d487))
|
||||
* **frontend:** containers and tab fixes v1 ([705703a](https://github.com/windmill-labs/windmill/commit/705703a5e2f2dc7ceb4c215221f72bf624799841))
|
||||
* **frontend:** containers and tab fixes v1 ([fac31c6](https://github.com/windmill-labs/windmill/commit/fac31c6628b289ad6aae92434e312c4be281a4d2))
|
||||
|
||||
## [1.69.0](https://github.com/windmill-labs/windmill/compare/v1.68.0...v1.69.0) (2023-02-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** Duplicate component ([#1228](https://github.com/windmill-labs/windmill/issues/1228)) ([089a6b6](https://github.com/windmill-labs/windmill/commit/089a6b6ae52e8d28dd15e2f9a6ad900c5853d0a1))
|
||||
* **frontend:** Properly delete tab content ([#1227](https://github.com/windmill-labs/windmill/issues/1227)) ([857ee5f](https://github.com/windmill-labs/windmill/commit/857ee5f318466d12bf0d41515451798df087ab74))
|
||||
* **frontend:** Support deeply nested components ([#1225](https://github.com/windmill-labs/windmill/issues/1225)) ([6ad876e](https://github.com/windmill-labs/windmill/commit/6ad876ebb45a934b7a4dc980cf38a5228d7d11f1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** .wmillignore whitelist behavior ([d543650](https://github.com/windmill-labs/windmill/commit/d543650b313c434e794ad800aefe4aeda83c0fed))
|
||||
|
||||
## [1.68.0](https://github.com/windmill-labs/windmill/compare/v1.67.4...v1.68.0) (2023-02-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** Add more app component CSS customisation ([#1218](https://github.com/windmill-labs/windmill/issues/1218)) ([6044e3b](https://github.com/windmill-labs/windmill/commit/6044e3b6ef92e89b8f15f38bc2d0986ec64105d5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** better ergonomics around workspace add ([40c12e6](https://github.com/windmill-labs/windmill/commit/40c12e6139c7b42d7ab169bab2dd37f8b43bea06))
|
||||
* **cli:** better ergonomics around workspaces ([3b7160e](https://github.com/windmill-labs/windmill/commit/3b7160e84aa454bdb5f343da99cfd97a6b319937))
|
||||
|
||||
## [1.67.4](https://github.com/windmill-labs/windmill/compare/v1.67.3...v1.67.4) (2023-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** workflow check for has_failure_module ([e54dc3f](https://github.com/windmill-labs/windmill/commit/e54dc3ff97e4454a15b9efe25cc12f6c9e1e176b))
|
||||
|
||||
## [1.67.3](https://github.com/windmill-labs/windmill/compare/v1.67.2...v1.67.3) (2023-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** ignone non wmill looking files ([ec57c59](https://github.com/windmill-labs/windmill/commit/ec57c5977f122b629a07e05bc3551662d518ce30))
|
||||
|
||||
## [1.67.2](https://github.com/windmill-labs/windmill/compare/v1.67.1...v1.67.2) (2023-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** ignone non wmill looking files ([969e89f](https://github.com/windmill-labs/windmill/commit/969e89f8bbc10f6712920321b70ede35f19ab9ed))
|
||||
|
||||
## [1.67.1](https://github.com/windmill-labs/windmill/compare/v1.67.0...v1.67.1) (2023-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** coloring nits ([3fa24ad](https://github.com/windmill-labs/windmill/commit/3fa24adad0a07ba2f469c545b28251b035efdf90))
|
||||
|
||||
## [1.67.0](https://github.com/windmill-labs/windmill/compare/v1.66.1...v1.67.0) (2023-02-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** Add app sub grids ([#1208](https://github.com/windmill-labs/windmill/issues/1208)) ([dbc59e9](https://github.com/windmill-labs/windmill/commit/dbc59e952143ee5813780ad13794cef4e036911c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** add --fail-conflicts to ci push ([0085b46](https://github.com/windmill-labs/windmill/commit/0085b46c1e3b8267fcafcb06ce72b4d820e49df5))
|
||||
|
||||
## [1.66.1](https://github.com/windmill-labs/windmill/compare/v1.66.0...v1.66.1) (2023-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** delete workspace instead of archiving them ([70dfc8b](https://github.com/windmill-labs/windmill/commit/70dfc8b8d0293d80da7db14caa1b9eb0ed67653d))
|
||||
|
||||
## [1.66.0](https://github.com/windmill-labs/windmill/compare/v1.65.0...v1.66.0) (2023-02-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add delete flows ([e81f7bd](https://github.com/windmill-labs/windmill/commit/e81f7bd7239b73710da2a4ddec0da7805c13da06))
|
||||
* CLI refactor v1 ([e31d2ae](https://github.com/windmill-labs/windmill/commit/e31d2ae27f886e774ffc429eea80057f4f9f4213))
|
||||
* **frontend:** Add image app component ([#1213](https://github.com/windmill-labs/windmill/issues/1213)) ([a4b773a](https://github.com/windmill-labs/windmill/commit/a4b773af294554c5787f02ebda363c8d9a3eff1b))
|
||||
|
||||
## [1.65.0](https://github.com/windmill-labs/windmill/compare/v1.64.0...v1.65.0) (2023-02-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **apps:** add asJson for customcss ([71d6dad](https://github.com/windmill-labs/windmill/commit/71d6dad37cc239952ce7799609c02474b0b1fc81))
|
||||
* **apps:** add custom css for apps ([7f00e1c](https://github.com/windmill-labs/windmill/commit/7f00e1c1a8f2e905b0677d82ba547f55dc23b3e0))
|
||||
* **backend:** Zip Workspace Export ([#1201](https://github.com/windmill-labs/windmill/issues/1201)) ([5d109b3](https://github.com/windmill-labs/windmill/commit/5d109b3cd4b7749788f9cb9fcbe1949c45eedf1f))
|
||||
* **frontend:** Add divider app component ([#1209](https://github.com/windmill-labs/windmill/issues/1209)) ([c33e79e](https://github.com/windmill-labs/windmill/commit/c33e79e0b8d5ba1103d87fdd47fcd0e1071e19de))
|
||||
* **frontend:** Add file input app component ([#1211](https://github.com/windmill-labs/windmill/issues/1211)) ([d4b6d69](https://github.com/windmill-labs/windmill/commit/d4b6d691264bf21e4e2c97548aaad9aa80678a6b))
|
||||
* **frontend:** Add icon app component ([#1207](https://github.com/windmill-labs/windmill/issues/1207)) ([e4791c2](https://github.com/windmill-labs/windmill/commit/e4791c2b7e3a0e6b90c37bc1200f9cd0ab3b6845))
|
||||
|
||||
## [1.64.0](https://github.com/windmill-labs/windmill/compare/v1.63.2...v1.64.0) (2023-02-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** Trigger settings drawer with URL hash ([#1185](https://github.com/windmill-labs/windmill/issues/1185)) ([8445697](https://github.com/windmill-labs/windmill/commit/8445697e31394ac11f3b8aa10af1546cc9c0041c))
|
||||
|
||||
## [1.63.2](https://github.com/windmill-labs/windmill/compare/v1.63.1...v1.63.2) (2023-02-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **psql:** update pg client ([a2fbc57](https://github.com/windmill-labs/windmill/commit/a2fbc5702509bb259bae106baa9a6146360ec5dd))
|
||||
|
||||
## [1.63.1](https://github.com/windmill-labs/windmill/compare/v1.63.0...v1.63.1) (2023-02-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update hub sync script ([03eb144](https://github.com/windmill-labs/windmill/commit/03eb1444c4a5dfbd170ba8d200784e530ca2f771))
|
||||
|
||||
## [1.63.0](https://github.com/windmill-labs/windmill/compare/v1.62.0...v1.63.0) (2023-02-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add mem peak info ([f584062](https://github.com/windmill-labs/windmill/commit/f584062f13aa7da8e767fd35de1aef7bbb67c3c8))
|
||||
* **frontend:** Minimal support for custom filenames ([#1190](https://github.com/windmill-labs/windmill/issues/1190)) ([b03b3be](https://github.com/windmill-labs/windmill/commit/b03b3be154efb0984f9623c27acc05617f125bc5))
|
||||
* **worker:** set oom_adj to 1000 to prioritize killing subprocess ([265fbc5](https://github.com/windmill-labs/windmill/commit/265fbc5835d029d510a794e171392884cb20bdae))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **python:** return none if argument is missing ([3f2754b](https://github.com/windmill-labs/windmill/commit/3f2754b3305f6cb65373d532ff0db6020bf07e45))
|
||||
* Update references to the docs ([#1191](https://github.com/windmill-labs/windmill/issues/1191)) ([a574270](https://github.com/windmill-labs/windmill/commit/a574270bc259f423c984259cd7d9a6d91b77815c))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
14
Caddyfile
14
Caddyfile
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ ARG features=""
|
||||
|
||||
COPY --from=planner /windmill/recipe.json recipe.json
|
||||
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo chef cook --release --features "$features" --recipe-path recipe.json
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true RUST_BACKTRACE=1 cargo chef cook --release --features "$features" --recipe-path recipe.json
|
||||
|
||||
COPY ./openflow.openapi.yaml /openflow.openapi.yaml
|
||||
COPY ./backend ./
|
||||
@@ -85,7 +85,8 @@ COPY .git/ .git/
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features"
|
||||
|
||||
|
||||
FROM python:3.11.1-slim-buster
|
||||
FROM python:3.11.2-slim-buster
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ARG APP=/usr/src/app
|
||||
|
||||
@@ -129,6 +130,10 @@ COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
COPY --from=denoland/deno:latest /usr/bin/deno /usr/bin/deno
|
||||
|
||||
# docker does not support conditional COPY and we want to use the same Dockerfile for both amd64 and arm64 and privilege the official image
|
||||
COPY --from=lukechannings/deno:latest /usr/bin/deno /usr/bin/deno-arm
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then rm /usr/bin/deno-arm; elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then mv /usr/bin/deno-arm /usr/bin/deno; fi
|
||||
|
||||
RUN mkdir -p ${APP}
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
8
LICENSE
8
LICENSE
@@ -8,5 +8,9 @@ or belonging to one of the below cases:
|
||||
|
||||
The files under backend/ are AGPL Licensed.
|
||||
The files under frontend/ are AGPL Licensed.
|
||||
The files under python-client/ are Apache 2.0 Licensed.
|
||||
The files under community/ are Apache 2.0 Licensed.
|
||||
The files under python-client/ deno-client/ go-client/ are Apache 2.0 Licensed.
|
||||
|
||||
The openapi files, including the OpenFlow spec is Apache 2.0 Licensed.
|
||||
|
||||
All third party components incorporated into the Windmill Software are licensed under the
|
||||
original license provided by the owner of the applicable component.
|
||||
|
||||
148
README.md
148
README.md
@@ -5,7 +5,7 @@
|
||||
<em>.</em>
|
||||
</p>
|
||||
<p align=center>
|
||||
Open-source developer infrastructure for internal tools. Self-hostable alternative to Airplane, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs to trigger workflows and scripts as internal apps. Scripts are turned into UIs and no-code modules, no-code modules can be composed into very rich flows, and script and flows can be triggered from internal UIs made with a low-code builder. The script languages supported are: Python, Typescript, Go, Bash.
|
||||
Open-source developer infrastructure for internal tools. Self-hostable alternative to Airplane, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs to trigger workflows and scripts as internal apps. Scripts are turned into UIs and no-code modules, no-code modules can be composed into very rich flows, and script and flows can be triggered from internal UIs made with a low-code builder. The script languages supported are: Python, Typescript, Go, Bash, SQL.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -20,55 +20,35 @@ Open-source developer infrastructure for internal tools. Self-hostable alternati
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**Try it (personal workspaces are free forever)**: <https://app.windmill.dev>
|
||||
|
||||
**Documentation**: <https://docs.windmill.dev>
|
||||
|
||||
**Discord**: <https://discord.gg/V7PM2YHsPB>
|
||||
|
||||
**Hub**: <https://hub.windmill.dev>
|
||||
|
||||
**Contributor's guide**: <https://docs.windmill.dev/docs/contributors_guide>
|
||||
|
||||
**Roadmap**: <https://github.com/orgs/windmill-labs/projects/2>
|
||||
|
||||
You can show your support for the project by starring this repo.
|
||||
|
||||
Windmill Labs offers commercial licenses, an enterprise edition, local hub
|
||||
mirrors, and support: contact ruben@windmill.dev.
|
||||
|
||||
---
|
||||
|
||||
# Windmill
|
||||
|
||||
<p align="center">
|
||||
<b>Disclaimer: </b>Windmill is in <b>BETA</b>. It is secure to run in production but we are still <a href="https://github.com/orgs/windmill-labs/projects/2">improving the product fast<a/>.
|
||||
<a href="https://app.windmill.dev">Try it</a> - <a href="https://docs.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="https://docs.windmill.dev/docs/misc/contributing">Contributor's guide</a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
# Windmill - Turn scripts into workflows and UIs that you can share and run at scale
|
||||
|
||||
Windmill is <b>fully open-sourced (AGPLv3)</b>:
|
||||
Windmill is <b>fully open-sourced (AGPLv3)</b> and Windmill Labs offers dedicated instance and commercial support and licenses.
|
||||
|
||||
- [Windmill](#windmill)
|
||||

|
||||
|
||||
https://user-images.githubusercontent.com/275584/218350457-bc2fdc3b-e667-4da5-a2bd-3bacc1f0ec79.mp4
|
||||
|
||||
- [Windmill - Turn scripts into workflows and UIs that you can share and run at scale](#windmill---turn-scripts-into-workflows-and-uis-that-you-can-share-and-run-at-scale)
|
||||
- [Main Concepts](#main-concepts)
|
||||
- [Show me some actual script code](#show-me-some-actual-script-code)
|
||||
- [CLI](#cli)
|
||||
- [Layout](#layout)
|
||||
- [Running scripts locally](#running-scripts-locally)
|
||||
- [Stack](#stack)
|
||||
- [Security](#security)
|
||||
- [Sandboxing and workload isolation](#sandboxing-and-workload-isolation)
|
||||
- [Sandboxing](#sandboxing)
|
||||
- [Secrets, credentials and sensitive values](#secrets-credentials-and-sensitive-values)
|
||||
- [Performance](#performance)
|
||||
- [Architecture](#architecture)
|
||||
- [Big-picture Architecture](#big-picture-architecture)
|
||||
- [Technical Architecture](#technical-architecture)
|
||||
- [How to self-host](#how-to-self-host)
|
||||
- [Docker compose](#docker-compose)
|
||||
- [Kubernetes (k8s) and Helm charts](#kubernetes-k8s-and-helm-charts)
|
||||
- [Postgres without superuser](#postgres-without-superuser)
|
||||
- [Commercial license](#commercial-license)
|
||||
- [OAuth for self-hosting (very optional)](#oauth-for-self-hosting-very-optional)
|
||||
- [OAuth for self-hosting](#oauth-for-self-hosting)
|
||||
- [Resource types](#resource-types)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Run a local dev setup](#run-a-local-dev-setup)
|
||||
@@ -99,20 +79,49 @@ through webhooks.
|
||||
|
||||
You can build your entire infra on top of Windmill!
|
||||
|
||||
## Show me some actual script code
|
||||
|
||||
```typescript
|
||||
import * as wmill from "https://deno.land/x/windmill@v1.62.0/mod.ts"
|
||||
//import any dependency from npm
|
||||
|
||||
import cowsay from 'npm:cowsay@1.5.0'
|
||||
|
||||
export async function main(
|
||||
a: number,
|
||||
// unions generate enums
|
||||
b: "my" | "enum",
|
||||
// default parameters prefill the field
|
||||
d = "default arg",
|
||||
// nested objects work c = { nested: "object" },
|
||||
// permissioned and typed json
|
||||
db: wmill.Resource<"postgresql">) {
|
||||
|
||||
const email = Deno.env.get('WM_EMAIL')
|
||||
// variables are permissioned and by path
|
||||
let variable = await wmill.getVariable('f/company-folder/my_secret')
|
||||
const lastTimeRun = await wmill.getState()
|
||||
// logs are printed and always inspectable
|
||||
console.log(cowsay.say({ text: "hello " + email + " " + lastTimeRun }))
|
||||
await wmill.setState(Date.now())
|
||||
|
||||
// return is serialized as JSON
|
||||
return { foo: d, variable };
|
||||
}
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
We have a powerful CLI to interact with the windmill platform and sync your
|
||||
scripts from your own github repo. See
|
||||
scripts from local files, github repos and to run scripts and flows on the instance from local commands. See
|
||||
[more details](https://github.com/windmill-labs/windmill/tree/main/cli)
|
||||
|
||||

|
||||
|
||||
## Layout
|
||||
|
||||
- `backend/`: Rust backend
|
||||
- `frontend`: Svelte frontend
|
||||
- `lsp/`: Lsp asssistant for the monaco editor
|
||||
- `<lang>-client/`: Windmill client for the given `<lang>`
|
||||
### Running scripts locally
|
||||
|
||||
You can run your script locally easily, you simply need to pass the right environment variables for the `wmill` client library to fetch resource and variables from your instance if necessary. See more: <https://docs.windmill.dev/docs/advanced/local_development/>
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -135,7 +144,7 @@ scripts from your own github repo. See
|
||||
|
||||
## Security
|
||||
|
||||
### Sandboxing and workload isolation
|
||||
### Sandboxing
|
||||
|
||||
Windmill uses [nsjail](https://github.com/google/nsjail) on top of the deno
|
||||
sandboxing. It is production multi-tenant grade secure. Do not take our word for
|
||||
@@ -161,33 +170,23 @@ back to the database is ~50ms. A typical lightweight deno job will take around
|
||||
|
||||
<p align="center">
|
||||
|
||||
### Big-picture Architecture
|
||||
|
||||
<img src="./imgs/diagram.svg">
|
||||
|
||||
### Technical Architecture
|
||||
|
||||
<img src="./imgs/architecture.svg">
|
||||
|
||||
</p>
|
||||
|
||||
## How to self-host
|
||||
|
||||
We only provide docker-compose setup here. For more advanced setups, like
|
||||
compiling from source or using without a postgres super user, see
|
||||
[documentation](https://docs.windmill.dev/docs/how-tos/self_host)
|
||||
[documentation](https://docs.windmill.dev/docs/advanced/self_host)
|
||||
|
||||
### Docker compose
|
||||
|
||||
`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.
|
||||
|
||||
To disable nsjail altogether, set `DISABLE_NSJAIL=true`.
|
||||
|
||||
The default super-admin user is: admin@windmill.dev / changeme
|
||||
|
||||
@@ -195,7 +194,14 @@ From there, you can create other users (do not forget to change the password!)
|
||||
|
||||
### Kubernetes (k8s) and Helm charts
|
||||
|
||||
We publish helm charts at: <https://github.com/windmill-labs/windmill-helm-charts>
|
||||
We publish helm charts at:
|
||||
<https://github.com/windmill-labs/windmill-helm-charts>
|
||||
|
||||
### Postgres without superuser
|
||||
|
||||
If you do not want, or cannot (for instance, in AWS Aurora or Cloud sql) use a postgres superuser,
|
||||
you can run `./init-db-as-superuser.sql` to init the required users for windmill.
|
||||
|
||||
|
||||
### Commercial license
|
||||
|
||||
@@ -207,14 +213,14 @@ comfortable with AGPLv3.
|
||||
To re-expose any Windmill parts to your users as a feature of your product, or
|
||||
to build a feature on top of Windmill, to comply with AGPLv3 your product must
|
||||
be AGPLv3 or you must get a commercial license. Contact us at
|
||||
<license@windmill.dev> if you have any doubts.
|
||||
<ruben@windmill.dev> if you have any doubts.
|
||||
|
||||
In addition, a commercial license grants you a dedicated engineer to transition
|
||||
your current infrastructure to Windmill, support with tight SLA, audit logs
|
||||
export features, SSO, unlimited users creation, advanced permission managing
|
||||
features such as groups and the ability to create more than one workspace.
|
||||
|
||||
### OAuth for self-hosting (very optional)
|
||||
### OAuth for self-hosting
|
||||
|
||||
To get the same oauth integrations as Windmill Cloud, mount `oauth.json` with
|
||||
the following format:
|
||||
@@ -231,12 +237,13 @@ the following format:
|
||||
|
||||
and mount it at `/usr/src/app/oauth.json`.
|
||||
|
||||
The redirect url for the oauth clients is: `<instance_url>/user/login_callback/<client>`
|
||||
The redirect url for the oauth clients is:
|
||||
`<instance_url>/user/login_callback/<client>`
|
||||
|
||||
[The list of all possible "connect an app" oauth clients](https://github.com/windmill-labs/windmill/blob/main/backend/oauth_connect.json)
|
||||
|
||||
To add more "connect an app" OAuth clients to the Windmill project, read the
|
||||
[Contributor's guide](https://docs.windmill.dev/docs/contributors_guide). We
|
||||
[Contributor's guide](https://docs.windmill.dev/docs/misc/contributing). We
|
||||
welcome contributions!
|
||||
|
||||
You may also add your own custom OAuth2 IdP and OAuth2 Resource provider:
|
||||
@@ -268,17 +275,19 @@ You may also add your own custom OAuth2 IdP and OAuth2 Resource provider:
|
||||
### Resource types
|
||||
|
||||
You will also want to import all the approved resource types from
|
||||
[WindmillHub](https://hub.windmill.dev). There is no automatic way to do this
|
||||
automatically currently, but it will be possible using a command with the
|
||||
upcoming CLI tool.
|
||||
[WindmillHub](https://hub.windmill.dev). A setup script will prompt
|
||||
you to have it being synced automatically everyday.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Environment Variable name | Default | Description | Api Server/Worker/All |
|
||||
| ------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| 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 |
|
||||
| DISABLE_NSJAIL | true | Disable Nsjail Sandboxing | Worker |
|
||||
| SERVER_BIND_ADDR | 0.0.0.0 | IP Address on which to bind listening socket | Server |
|
||||
| PORT | 8000 | Exposed port | Server | |
|
||||
| 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 |
|
||||
@@ -292,8 +301,7 @@ upcoming CLI tool.
|
||||
| S3_CACHE_BUCKET (EE only) | None | The S3 bucket to sync the cache of the workers to | Worker |
|
||||
| TAR_CACHE_RATE (EE only) | 100 | The rate at which to tar the cache of the workers. 100 means every 100th job in average (uniformly randomly distributed). | Worker |
|
||||
| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server |
|
||||
| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server |
|
||||
| SERVE_CSP | None | The CSP directives to use when serving the frontend static assets | Server |
|
||||
| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server | |
|
||||
| DENO_PATH | /usr/bin/deno | The path to the deno binary. | Worker |
|
||||
| PYTHON_PATH | /usr/local/bin/python3 | The path to the python binary. | Worker |
|
||||
| GO_PATH | /usr/bin/go | The path to the go binary. | Worker |
|
||||
@@ -308,12 +316,12 @@ upcoming CLI tool.
|
||||
| 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 |
|
||||
| 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 |
|
||||
| NEW_USER_WEBHOOK | None | Webhook to notify of a new user added, signup/invite. Can hook back to windmill to send emails | Server |
|
||||
|
||||
## Run a local dev setup
|
||||
|
||||
@@ -360,4 +368,4 @@ running options.
|
||||
|
||||
## Copyright
|
||||
|
||||
Windmill Labs, Inc 2022
|
||||
Windmill Labs, Inc 2023
|
||||
|
||||
790
backend/Cargo.lock
generated
790
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.61.1"
|
||||
version = "1.74.2"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -19,7 +19,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.61.1"
|
||||
version = "1.74.2"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -28,7 +28,11 @@ name = "windmill"
|
||||
path = "./src/main.rs"
|
||||
|
||||
[features]
|
||||
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise"]
|
||||
enterprise = [
|
||||
"windmill-worker/enterprise",
|
||||
"windmill-queue/enterprise",
|
||||
"windmill-api/enterprise",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -83,7 +87,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.16.2" }
|
||||
cookie = { version = "0.17.0" }
|
||||
phf = { version = "0.11", features = ["macros"] }
|
||||
rust-embed = "^6"
|
||||
mime_guess = "^2"
|
||||
@@ -119,9 +123,9 @@ regex = "^1"
|
||||
deno_core = "^0"
|
||||
async-recursion = "^1"
|
||||
swc_common = "^0"
|
||||
swc_ecma_parser = "^0"
|
||||
swc_ecma_ast = "^0"
|
||||
base64 = "^0"
|
||||
swc_ecma_parser = "0.128.2"
|
||||
swc_ecma_ast = "0.98.1"
|
||||
base64 = "0.21.0"
|
||||
unicode-general-category = "^0"
|
||||
hmac = "0.12.1"
|
||||
sha2 = "0.10.6"
|
||||
@@ -144,4 +148,8 @@ 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"] }
|
||||
async-stripe = { version = "0.14", features = [
|
||||
"runtime-tokio-hyper",
|
||||
"checkout",
|
||||
] }
|
||||
async_zip = { version = "0.0.11", features = ["full"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
12
backend/migrations/20221214105402_first_time_users.up.sql
Normal file
12
backend/migrations/20221214105402_first_time_users.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE
|
||||
password
|
||||
ADD
|
||||
first_time_user boolean NOT NULL DEFAULT (false);
|
||||
|
||||
UPDATE
|
||||
password
|
||||
SET
|
||||
first_time_user = true
|
||||
WHERE
|
||||
email = 'admin@windmill.dev';
|
||||
1
backend/migrations/20230126023323_webhook.down.sql
Normal file
1
backend/migrations/20230126023323_webhook.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
5
backend/migrations/20230126023323_webhook.up.sql
Normal file
5
backend/migrations/20230126023323_webhook.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE
|
||||
workspace_settings
|
||||
ADD
|
||||
COLUMN webhook text;
|
||||
1
backend/migrations/20230204182500_add_mem_peak.down.sql
Normal file
1
backend/migrations/20230204182500_add_mem_peak.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
3
backend/migrations/20230204182500_add_mem_peak.up.sql
Normal file
3
backend/migrations/20230204182500_add_mem_peak.up.sql
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
1584
backend/migrations/20230210145514_first_time_app.up.sql
Normal file
1584
backend/migrations/20230210145514_first_time_app.up.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
1162
backend/migrations/20230213145545_change_admin_app.up.sql
Normal file
1162
backend/migrations/20230213145545_change_admin_app.up.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
1169
backend/migrations/20230214022802_goto_logout_setup.up.sql
Normal file
1169
backend/migrations/20230214022802_goto_logout_setup.up.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -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.63.1/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';
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
1170
backend/migrations/20230214144244_improve_app_setup.up.sql
Normal file
1170
backend/migrations/20230214144244_improve_app_setup.up.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Add up migration script here
|
||||
|
||||
UPDATE script SET content = 'import wmill from "https://deno.land/x/wmill@v1.70.1/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';
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE queue ADD COLUMN root_job uuid;
|
||||
ALTER TABLE queue ADD COLUMN leaf_jobs jsonb;
|
||||
@@ -16,4 +16,5 @@ unicode-general-category.workspace = true
|
||||
itertools.workspace = true
|
||||
anyhow.workspace = true
|
||||
regex.workspace = true
|
||||
lazy_static.workspace = true
|
||||
lazy_static.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(non_snake_case)] // TODO: switch to parse_* function naming
|
||||
|
||||
use anyhow::anyhow;
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use windmill_parser::{Arg, MainArgSignature, Typ};
|
||||
@@ -17,19 +19,32 @@ pub fn parse_bash_sig(code: &str) -> windmill_common::error::Result<MainArgSigna
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: Regex = Regex::new(r#"(?m)^(\w+)="\$(?:(\d+)|\{(\d+):-(.*)\})"$"#).unwrap();
|
||||
}
|
||||
|
||||
fn parse_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
let mut hm = HashMap::new();
|
||||
let re = Regex::new(r#"(?m)^(\w+)="\$(\d+)"$"#).unwrap();
|
||||
for cap in re.captures_iter(code) {
|
||||
hm.insert(cap[2].parse::<i32>()?, cap[1].to_string());
|
||||
let mut hm: HashMap<i32, (String, Option<String>)> = HashMap::new();
|
||||
for cap in RE.captures_iter(code) {
|
||||
hm.insert(
|
||||
cap.get(2)
|
||||
.or(cap.get(3))
|
||||
.and_then(|x| x.as_str().parse::<i32>().ok())
|
||||
.ok_or_else(|| anyhow!("Impossible to parse arg digit"))?,
|
||||
(
|
||||
cap[1].to_string(),
|
||||
cap.get(4).map(|x| x.as_str().to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut args = vec![];
|
||||
for i in 1..20 {
|
||||
if hm.contains_key(&i) {
|
||||
let (name, default) = hm.get(&i).unwrap();
|
||||
args.push(Arg {
|
||||
name: hm[&i].clone(),
|
||||
name: name.clone(),
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
default: default.clone().map(|x| json!(x)),
|
||||
otyp: None,
|
||||
has_default: false,
|
||||
});
|
||||
@@ -43,6 +58,8 @@ fn parse_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -50,8 +67,7 @@ mod tests {
|
||||
let code = r#"
|
||||
token="$1"
|
||||
image="$2"
|
||||
digest="${3:-latest}"
|
||||
foo="$4"
|
||||
digest="${3:-latest with spaces}"
|
||||
|
||||
"#;
|
||||
//println!("{}", serde_json::to_string()?);
|
||||
@@ -74,6 +90,13 @@ foo="$4"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
name: "digest".to_string(),
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("latest with spaces")),
|
||||
has_default: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,10 +17,8 @@ use serde_json::json;
|
||||
use windmill_common::error;
|
||||
use windmill_parser::{json_to_typ, Arg, MainArgSignature, Typ};
|
||||
|
||||
use rustpython_parser::{
|
||||
ast::{Constant, ExprKind, Located, StmtKind},
|
||||
parser,
|
||||
};
|
||||
use rustpython_parser as parser;
|
||||
use rustpython_parser::ast::{Constant, ExprKind, Located, StmtKind};
|
||||
|
||||
const DEF_MAIN: &str = "def main(";
|
||||
const FUNCTION_CALL: &str = "<function call>";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,16 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use git_version::git_version;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::utils::rd_string;
|
||||
use windmill_worker::WorkerConfig;
|
||||
|
||||
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
|
||||
const DEFAULT_NUM_WORKERS: usize = 3;
|
||||
const DEFAULT_PORT: u16 = 8000;
|
||||
const DEFAULT_SERVER_BIND_ADDR: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 0);
|
||||
|
||||
mod ee;
|
||||
|
||||
@@ -26,7 +28,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let num_workers = std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i32>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_NUM_WORKERS as i32);
|
||||
.unwrap_or(DEFAULT_NUM_WORKERS as i32);
|
||||
|
||||
let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
|
||||
.ok()
|
||||
@@ -38,6 +40,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
let server_bind_address: IpAddr = std::env::var("SERVER_BIND_ADDR")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok() )
|
||||
.unwrap_or(IpAddr::from(DEFAULT_SERVER_BIND_ADDR));
|
||||
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_PORT as u16);
|
||||
let base_internal_url: String = std::env::var("BASE_INTERNAL_URL")
|
||||
.unwrap_or_else(|_| format!("http://localhost:{}", port.to_string()));
|
||||
|
||||
let server_mode = !std::env::var("DISABLE_SERVER")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
@@ -52,99 +66,87 @@ async fn main() -> anyhow::Result<()> {
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||
let shutdown_signal = windmill_common::shutdown_signal(tx);
|
||||
|
||||
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
|
||||
#[cfg(feature = "enterprise")]
|
||||
tracing::info!(
|
||||
"
|
||||
##############################
|
||||
Windmill Enterprise Edition {GIT_VERSION}
|
||||
##############################"
|
||||
);
|
||||
|
||||
let base_internal_url =
|
||||
std::env::var("BASE_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
let timeout = std::env::var("TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i32>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_TIMEOUT);
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
tracing::info!(
|
||||
"
|
||||
##############################
|
||||
Windmill Community Edition {GIT_VERSION}
|
||||
##############################"
|
||||
);
|
||||
|
||||
display_config(vec![
|
||||
"DISABLE_NSJAIL",
|
||||
"DISABLE_SERVER",
|
||||
"NUM_WORKERS",
|
||||
"METRICS_ADDR",
|
||||
"JSON_FMT",
|
||||
"BASE_URL",
|
||||
"BASE_INTERNAL_URL",
|
||||
"TIMEOUT",
|
||||
"SLEEP_QUEUE",
|
||||
"MAX_LOG_SIZE",
|
||||
"SERVER_BIND_ADDR",
|
||||
"PORT",
|
||||
"KEEP_JOB_DIR",
|
||||
"S3_CACHE_BUCKET",
|
||||
"TAR_CACHE_RATE",
|
||||
"COOKIE_DOMAIN",
|
||||
"PYTHON_PATH",
|
||||
"DENO_PATH",
|
||||
"GO_PATH",
|
||||
"PIP_INDEX_URL",
|
||||
"PIP_EXTRA_INDEX_URL",
|
||||
"PIP_TRUSTED_HOST",
|
||||
"PATH",
|
||||
"HOME",
|
||||
"DATABASE_CONNECTIONS",
|
||||
"TIMEOUT_WAIT_RESULT",
|
||||
"QUEUE_LIMIT_WAIT_RESULT",
|
||||
"DENO_AUTH_TOKENS",
|
||||
"DENO_FLAGS",
|
||||
"PIP_LOCAL_DEPENDENCIES",
|
||||
"ADDITIONAL_PYTHON_PATHS",
|
||||
"INCLUDE_HEADERS",
|
||||
"WHITELIST_WORKSPACES",
|
||||
"BLACKLIST_WORKSPACES",
|
||||
"NEW_USER_WEBHOOK",
|
||||
"CLOUD_HOSTED",
|
||||
]);
|
||||
|
||||
if server_mode || num_workers > 0 {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
|
||||
let addr = SocketAddr::from((server_bind_address, port));
|
||||
|
||||
let base_url2 = base_url.clone();
|
||||
let server_f = async {
|
||||
if server_mode {
|
||||
windmill_api::run_server(db.clone(), addr, base_url, rx.resubscribe()).await?;
|
||||
windmill_api::run_server(db.clone(), addr, rx.resubscribe()).await?;
|
||||
}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
let base_url = base_url2.clone();
|
||||
let workers_f = async {
|
||||
if num_workers > 0 {
|
||||
let sleep_queue = std::env::var("SLEEP_QUEUE")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u64>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_SLEEP_QUEUE);
|
||||
let disable_nuser = std::env::var("DISABLE_NUSER")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
let disable_nsjail = std::env::var("DISABLE_NSJAIL")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(true);
|
||||
let keep_job_dir = std::env::var("KEEP_JOB_DIR")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
let license_key = std::env::var("LICENSE_KEY").ok();
|
||||
let sync_bucket = std::env::var("S3_CACHE_BUCKET")
|
||||
.ok()
|
||||
.map(|e| Some(e))
|
||||
.unwrap_or(None);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
tracing::info!(
|
||||
"
|
||||
##############################
|
||||
Windmill Enterprise Edition {GIT_VERSION} LICENSE_KEY: {license_key:?}, S3_CACHE_BUCKET: {sync_bucket:?}
|
||||
##############################"
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
tracing::info!(
|
||||
"
|
||||
##############################
|
||||
Windmill Community Edition {GIT_VERSION}
|
||||
##############################"
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
"DISABLE_NSJAIL: {disable_nsjail}, DISABLE_NUSER: {disable_nuser}, BASE_URL: \
|
||||
{base_url}, SLEEP_QUEUE: {sleep_queue}, NUM_WORKERS: {num_workers}, TIMEOUT: \
|
||||
{timeout}, KEEP_JOB_DIR: {keep_job_dir}"
|
||||
);
|
||||
|
||||
run_workers(
|
||||
db.clone(),
|
||||
addr,
|
||||
timeout,
|
||||
num_workers,
|
||||
sleep_queue,
|
||||
WorkerConfig {
|
||||
disable_nsjail,
|
||||
disable_nuser,
|
||||
base_internal_url,
|
||||
base_url,
|
||||
keep_job_dir,
|
||||
},
|
||||
rx.resubscribe(),
|
||||
sync_bucket,
|
||||
license_key,
|
||||
num_workers,
|
||||
base_internal_url.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
let base_url = base_url2;
|
||||
let monitor_f = async {
|
||||
if server_mode {
|
||||
monitor_db(&db, timeout, base_url, rx.resubscribe());
|
||||
monitor_db(&db, rx.resubscribe(), &base_internal_url);
|
||||
}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
@@ -163,34 +165,46 @@ Windmill Community Edition {GIT_VERSION}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_config(envs: Vec<&str>) {
|
||||
tracing::info!(
|
||||
"config: {}",
|
||||
envs.iter()
|
||||
.filter(|env| std::env::var(env).is_ok())
|
||||
.map(|env| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
env,
|
||||
std::env::var(env).unwrap_or_else(|_| "not set".to_string())
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn monitor_db(
|
||||
db: &Pool<Postgres>,
|
||||
timeout: i32,
|
||||
base_url: String,
|
||||
rx: tokio::sync::broadcast::Receiver<()>,
|
||||
base_internal_url: &str,
|
||||
) {
|
||||
let db1 = db.clone();
|
||||
let db2 = db.clone();
|
||||
|
||||
let rx2 = rx.resubscribe();
|
||||
|
||||
let base_internal_url = base_internal_url.to_string();
|
||||
tokio::spawn(async move {
|
||||
windmill_worker::handle_zombie_jobs_periodically(&db1, timeout, &base_url, rx).await
|
||||
windmill_worker::handle_zombie_jobs_periodically(&db1, rx, &base_internal_url).await
|
||||
});
|
||||
tokio::spawn(async move { windmill_api::delete_expired_items_perdiodically(&db2, rx2).await });
|
||||
}
|
||||
|
||||
pub async fn run_workers(
|
||||
db: Pool<Postgres>,
|
||||
addr: SocketAddr,
|
||||
timeout: i32,
|
||||
num_workers: i32,
|
||||
sleep_queue: u64,
|
||||
worker_config: WorkerConfig,
|
||||
rx: tokio::sync::broadcast::Receiver<()>,
|
||||
mut periodic_script: Option<String>,
|
||||
license_key: Option<String>,
|
||||
num_workers: i32,
|
||||
base_internal_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let license_key = std::env::var("LICENSE_KEY").ok();
|
||||
#[cfg(feature = "enterprise")]
|
||||
ee::verify_license_key(license_key)?;
|
||||
|
||||
@@ -198,12 +212,6 @@ pub async fn run_workers(
|
||||
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!(
|
||||
"NSJAIL to sandbox process in untrusted environments is an enterprise feature but allowed to be used for testing purposes"
|
||||
);
|
||||
}
|
||||
|
||||
let instance_name = rd_string(5);
|
||||
let monitor = tokio_metrics::TaskMonitor::new();
|
||||
@@ -223,22 +231,17 @@ pub async fn run_workers(
|
||||
let worker_name = format!("dt-worker-{}-{}", &instance_name, rd_string(5));
|
||||
let ip = ip.clone();
|
||||
let rx = rx.resubscribe();
|
||||
let worker_config = worker_config.clone();
|
||||
let wp = periodic_script.take();
|
||||
let base_internal_url = base_internal_url.clone();
|
||||
handles.push(tokio::spawn(monitor.instrument(async move {
|
||||
tracing::info!(addr = %addr.to_string(), worker = %worker_name, "starting worker");
|
||||
tracing::info!(worker = %worker_name, "starting worker");
|
||||
windmill_worker::run_worker(
|
||||
&db1,
|
||||
timeout,
|
||||
&instance_name,
|
||||
worker_name,
|
||||
i as u64,
|
||||
num_workers as u64,
|
||||
&ip,
|
||||
sleep_queue,
|
||||
worker_config,
|
||||
wp,
|
||||
rx,
|
||||
&base_internal_url,
|
||||
)
|
||||
.await
|
||||
})));
|
||||
|
||||
@@ -6,10 +6,8 @@ use windmill_common::{
|
||||
flow_status::{FlowStatus, FlowStatusModule},
|
||||
flows::{FlowModule, FlowModuleValue, FlowValue, InputTransform},
|
||||
scripts::ScriptLang,
|
||||
DEFAULT_SLEEP_QUEUE,
|
||||
};
|
||||
use windmill_queue::{get_queued_job, JobPayload, RawCode};
|
||||
use windmill_worker::WorkerConfig;
|
||||
|
||||
async fn initialize_tracing() {
|
||||
use std::sync::Once;
|
||||
@@ -89,14 +87,7 @@ impl ApiServer {
|
||||
let addr = sock.local_addr().unwrap();
|
||||
drop(sock);
|
||||
|
||||
let task = tokio::task::spawn({
|
||||
windmill_api::run_server(
|
||||
db.clone(),
|
||||
addr,
|
||||
format!("http://localhost:{}", addr.port()),
|
||||
rx,
|
||||
)
|
||||
});
|
||||
let task = tokio::task::spawn(windmill_api::run_server(db.clone(), addr, rx));
|
||||
|
||||
return Self { addr, tx, task };
|
||||
}
|
||||
@@ -845,6 +836,7 @@ impl RunJob {
|
||||
/* scheduled_for_o */ None,
|
||||
/* schedule_path */ None,
|
||||
/* parent_job */ None,
|
||||
/* root job */ None,
|
||||
/* is_flow_step */ false,
|
||||
/* running */ false,
|
||||
None,
|
||||
@@ -917,43 +909,20 @@ fn spawn_test_worker(
|
||||
) {
|
||||
let (tx, rx) = tokio::sync::broadcast::channel(1);
|
||||
let db = db.to_owned();
|
||||
let timeout = 4_000;
|
||||
let worker_instance: &str = "test worker instance";
|
||||
let worker_name: String = next_worker_name();
|
||||
let i_worker: u64 = Default::default();
|
||||
let num_workers: u64 = 2;
|
||||
let ip: &str = Default::default();
|
||||
let sleep_queue: u64 = DEFAULT_SLEEP_QUEUE / num_workers;
|
||||
let port = port;
|
||||
let worker_config = WorkerConfig {
|
||||
base_internal_url: format!("http://localhost:{port}"),
|
||||
base_url: format!("http://localhost:{port}"),
|
||||
disable_nuser: std::env::var("DISABLE_NUSER")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false),
|
||||
disable_nsjail: std::env::var("DISABLE_NSJAIL")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false),
|
||||
keep_job_dir: std::env::var("KEEP_JOB_DIR")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false),
|
||||
};
|
||||
let future = async move {
|
||||
let base_internal_url = format!("http://localhost:{}", port);
|
||||
windmill_worker::run_worker(
|
||||
&db,
|
||||
timeout,
|
||||
worker_instance,
|
||||
worker_name,
|
||||
i_worker,
|
||||
num_workers,
|
||||
ip,
|
||||
sleep_queue,
|
||||
worker_config,
|
||||
None,
|
||||
rx,
|
||||
&base_internal_url,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
@@ -8,12 +8,8 @@ edition.workspace = true
|
||||
name = "windmill_api"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "windmill_api"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
enterprise = ["windmill-queue/enterprise"]
|
||||
enterprise = ["windmill-queue/enterprise", "async-stripe"]
|
||||
|
||||
[dependencies]
|
||||
windmill-queue.workspace = true
|
||||
@@ -69,5 +65,7 @@ hmac.workspace = true
|
||||
cookie.workspace = true
|
||||
sha2.workspace = true
|
||||
urlencoding.workspace = true
|
||||
async-stripe.workspace = true
|
||||
lazy_static.workspace = true
|
||||
async-stripe = { workspace = true, optional = true }
|
||||
lazy_static.workspace = true
|
||||
prometheus.workspace = true
|
||||
async_zip.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.61.1
|
||||
version: 1.74.2
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -883,6 +883,8 @@ paths:
|
||||
type: string
|
||||
customer_id:
|
||||
type: string
|
||||
webhook:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/premium_info:
|
||||
get:
|
||||
@@ -962,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
|
||||
@@ -1063,6 +1092,10 @@ paths:
|
||||
- variable
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: already_encrypted
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
requestBody:
|
||||
description: new variable
|
||||
required: true
|
||||
@@ -1104,6 +1137,10 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- name: already_encrypted
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
requestBody:
|
||||
description: updated variable
|
||||
required: true
|
||||
@@ -2241,7 +2278,7 @@ paths:
|
||||
|
||||
/w/{workspace}/scripts/delete/h/{hash}:
|
||||
post:
|
||||
summary: delete script by hash (erase content but keep hash)
|
||||
summary: delete script by hash (erase content but keep hash, require admin)
|
||||
operationId: deleteScriptByHash
|
||||
tags:
|
||||
- script
|
||||
@@ -2256,6 +2293,23 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Script"
|
||||
|
||||
/w/{workspace}/scripts/delete/p/{path}:
|
||||
post:
|
||||
summary: delete all scripts at a given path (require admin)
|
||||
operationId: deleteScriptByPath
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: script path
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/scripts/get/p/{path}:
|
||||
get:
|
||||
summary: get script by path
|
||||
@@ -2501,11 +2555,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: skip_direct
|
||||
description: Skip checking that the node is part of the given flow.
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: job result
|
||||
@@ -2679,6 +2728,23 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/delete/{path}:
|
||||
delete:
|
||||
summary: delete flow by path
|
||||
operationId: deleteFlowByPath
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: flow delete
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/list:
|
||||
get:
|
||||
summary: list all available apps
|
||||
@@ -3263,6 +3329,8 @@ paths:
|
||||
type: boolean
|
||||
new_logs:
|
||||
type: string
|
||||
mem_peak:
|
||||
type: integer
|
||||
|
||||
/w/{workspace}/jobs/completed/get/{id}:
|
||||
get:
|
||||
@@ -3281,6 +3349,22 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CompletedJob"
|
||||
|
||||
/w/{workspace}/jobs/completed/get_result/{id}:
|
||||
get:
|
||||
summary: get completed job result
|
||||
operationId: getCompletedJobResult
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
responses:
|
||||
"200":
|
||||
description: result
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/jobs/completed/delete/{id}:
|
||||
post:
|
||||
summary: delete completed job (erase content but keep run id)
|
||||
@@ -4761,6 +4845,8 @@ components:
|
||||
type: string
|
||||
visible_to_owner:
|
||||
type: boolean
|
||||
mem_peak:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- running
|
||||
@@ -4847,6 +4933,8 @@ components:
|
||||
type: string
|
||||
visible_to_owner:
|
||||
type: boolean
|
||||
mem_peak:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- created_by
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::{
|
||||
jobs::script_path_to_payload,
|
||||
users::{require_owner_of_path, Authed, OptAuthed},
|
||||
variables::build_crypt,
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path, Query},
|
||||
@@ -20,7 +22,6 @@ use axum::{
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -169,7 +170,7 @@ async fn list_apps(
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("app_version.created_at", true)
|
||||
.and_where("app.workspace_id = ? OR app.workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("app.workspace_id = ?".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -310,11 +311,15 @@ 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>,
|
||||
Json(mut app): Json<CreateApp>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
app.policy.on_behalf_of_email = Some(authed.email);
|
||||
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO app
|
||||
(workspace_id, path, summary, policy, versions)
|
||||
@@ -356,17 +361,19 @@ 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> {
|
||||
async fn list_hub_apps(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
|
||||
let flows = list_elems_from_hub(
|
||||
http_client,
|
||||
&HTTP_CLIENT,
|
||||
"https://hub.windmill.dev/searchUiData?approved=true",
|
||||
&email,
|
||||
)
|
||||
@@ -377,10 +384,9 @@ async fn list_hub_apps(
|
||||
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,
|
||||
&HTTP_CLIENT,
|
||||
&format!("https://hub.windmill.dev/apps/{id}/json"),
|
||||
&email,
|
||||
false,
|
||||
@@ -395,6 +401,7 @@ pub async fn get_hub_app_by_id(
|
||||
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();
|
||||
@@ -418,6 +425,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))
|
||||
}
|
||||
@@ -425,6 +436,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>,
|
||||
@@ -454,7 +466,9 @@ async fn update_app(
|
||||
sqlb.set_str("summary", nsummary);
|
||||
}
|
||||
|
||||
if let Some(npolicy) = ns.policy {
|
||||
if let Some(mut npolicy) = ns.policy {
|
||||
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
|
||||
npolicy.on_behalf_of_email = Some(authed.email);
|
||||
sqlb.set(
|
||||
"policy",
|
||||
&format!(
|
||||
@@ -514,6 +528,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))
|
||||
}
|
||||
@@ -653,6 +675,7 @@ async fn execute_component(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -710,6 +733,9 @@ fn build_args(
|
||||
path: String,
|
||||
args: &Map<String, Value>,
|
||||
) -> Result<Map<String, Value>> {
|
||||
// disallow var and res access in args coming from the user for security reasons
|
||||
args.into_iter()
|
||||
.try_for_each(|x| disallow_var_res_access(x.1))?;
|
||||
let static_args = policy
|
||||
.triggerables
|
||||
.get(&path)
|
||||
@@ -730,3 +756,20 @@ fn build_args(
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn disallow_var_res_access(args: &serde_json::Value) -> Result<()> {
|
||||
match args {
|
||||
Value::Object(v) => v.into_iter().try_for_each(|x| disallow_var_res_access(x.1)),
|
||||
Value::Array(arr) => arr.into_iter().try_for_each(|v| disallow_var_res_access(v)),
|
||||
Value::String(s) => {
|
||||
if s.starts_with("$var:") || s.starts_with("$res:") {
|
||||
Err(Error::BadRequest(format!(
|
||||
"For security reasons, variable or resource access is not allowed as dynamic argument"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
*/
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
extract::{Extension, Path, Query},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use hyper::{HeaderMap, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use windmill_common::{
|
||||
error::{JsonResult, Result},
|
||||
utils::{not_found_if_none, StripPath},
|
||||
@@ -19,6 +20,7 @@ use windmill_common::{
|
||||
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
jobs::add_include_headers,
|
||||
users::Authed,
|
||||
};
|
||||
|
||||
@@ -83,13 +85,21 @@ pub async fn new_payload(
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct IncludeHeaderQuery {
|
||||
include_header: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_payload(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
Query(run_query): Query<IncludeHeaderQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> Result<StatusCode> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let args = add_include_headers(&run_query.include_header, headers, args.unwrap_or_default());
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE capture
|
||||
@@ -99,7 +109,7 @@ pub async fn update_payload(
|
||||
",
|
||||
&w_id,
|
||||
&path.to_path(),
|
||||
&payload,
|
||||
serde_json::json!(args),
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
use hyper::StatusCode;
|
||||
use reqwest::Client;
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use sql_builder::SqlBuilder;
|
||||
@@ -32,6 +31,8 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
schedule::clear_schedule,
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -40,6 +41,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/create", post(create_flow))
|
||||
.route("/update/*path", post(update_flow))
|
||||
.route("/archive/*path", post(archive_flow_by_path))
|
||||
.route("/delete/*path", delete(delete_flow_by_path))
|
||||
.route("/get/*path", get(get_flow_by_path))
|
||||
.route("/exists/*path", get(exists_flow_by_path))
|
||||
.route("/list_paths", get(list_paths))
|
||||
@@ -80,7 +82,7 @@ async fn list_flows(
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("edited_at", lq.order_desc.unwrap_or(true))
|
||||
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("o.workspace_id = ?".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -110,12 +112,9 @@ async fn list_flows(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
async fn list_hub_flows(
|
||||
Authed { email, .. }: Authed,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
async fn list_hub_flows(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
|
||||
let flows = list_elems_from_hub(
|
||||
http_client,
|
||||
&HTTP_CLIENT,
|
||||
"https://hub.windmill.dev/searchFlowData?approved=true",
|
||||
&email,
|
||||
)
|
||||
@@ -144,10 +143,9 @@ async fn list_paths(
|
||||
pub async fn get_hub_flow_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,
|
||||
&HTTP_CLIENT,
|
||||
&format!("https://hub.windmill.dev/flows/{id}/json"),
|
||||
&email,
|
||||
false,
|
||||
@@ -181,6 +179,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)> {
|
||||
@@ -221,6 +220,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(
|
||||
@@ -234,6 +237,7 @@ async fn create_flow(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -280,6 +284,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> {
|
||||
@@ -368,6 +373,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(
|
||||
@@ -381,6 +394,7 @@ async fn update_flow(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -415,13 +429,12 @@ async fn get_flow_by_path(
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flow_o = sqlx::query_as::<_, Flow>(
|
||||
"SELECT * FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
let flow_o =
|
||||
sqlx::query_as::<_, Flow>("SELECT * FROM flow WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let flow = not_found_if_none(flow_o, "Flow", path)?;
|
||||
@@ -435,8 +448,7 @@ async fn exists_flow_by_path(
|
||||
let path = path.to_path();
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id \
|
||||
= 'starter'))",
|
||||
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
@@ -450,6 +462,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();
|
||||
@@ -474,10 +487,50 @@ 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"))
|
||||
}
|
||||
|
||||
async fn delete_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();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM flow WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
"flows.delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(path),
|
||||
Some([("workspace", w_id.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
WebhookMessage::DeleteFlow { workspace: w_id, path: path.to_owned() },
|
||||
);
|
||||
|
||||
Ok(format!("Flow {path} deleted"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -587,8 +640,6 @@ mod tests {
|
||||
"type": "script",
|
||||
"path": "test"
|
||||
},
|
||||
"stop_after_if": null,
|
||||
"summary": null
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
@@ -597,15 +648,12 @@ mod tests {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"content": "test",
|
||||
"lock": null,
|
||||
"path": null,
|
||||
"language": "deno"
|
||||
},
|
||||
"stop_after_if": {
|
||||
"expr": "foo = 'bar'",
|
||||
"skip_if_stopped": false
|
||||
},
|
||||
"summary": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
@@ -627,8 +675,7 @@ mod tests {
|
||||
"stop_after_if": {
|
||||
"expr": "previous.isEmpty()",
|
||||
"skip_if_stopped": false,
|
||||
},
|
||||
"summary": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"failure_module": {
|
||||
@@ -642,8 +689,7 @@ mod tests {
|
||||
"stop_after_if": {
|
||||
"expr": "previous.isEmpty()",
|
||||
"skip_if_stopped": false
|
||||
},
|
||||
"summary": null
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(dbg!(serde_json::json!(fv)), dbg!(expect));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
users::Authed,
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -139,6 +140,7 @@ 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> {
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{FromRequest, Json, Path, Query},
|
||||
@@ -38,7 +36,7 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
users::{require_owner_of_path, Authed},
|
||||
variables::get_workspace_key,
|
||||
BaseUrl, QueueLimitWaitResult, TimeoutWaitResult,
|
||||
BASE_URL,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -104,10 +102,9 @@ pub fn global_service() -> Router {
|
||||
|
||||
async fn get_result_by_id(
|
||||
Extension(db): Extension<DB>,
|
||||
Query(ResultByIdQuery { skip_direct }): Query<ResultByIdQuery>,
|
||||
Path((w_id, flow_id, node_id)): Path<(String, String, String)>,
|
||||
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
|
||||
) -> windmill_common::error::JsonResult<serde_json::Value> {
|
||||
let res = windmill_queue::get_result_by_id(db, skip_direct, w_id, flow_id, node_id).await?;
|
||||
let res = windmill_queue::get_result_by_id(db, w_id, flow_id, node_id).await?;
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
@@ -154,8 +151,7 @@ pub async fn get_path_for_hash<'c>(
|
||||
hash: i64,
|
||||
) -> error::Result<String> {
|
||||
let path = sqlx::query_scalar!(
|
||||
"select path from script where hash = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter')",
|
||||
"select path from script where hash = $1 AND workspace_id = $2",
|
||||
hash,
|
||||
w_id
|
||||
)
|
||||
@@ -180,11 +176,6 @@ async fn get_job(
|
||||
Ok(Json(job))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultByIdQuery {
|
||||
pub skip_direct: bool,
|
||||
}
|
||||
|
||||
pub async fn get_job_by_id<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
w_id: &str,
|
||||
@@ -258,6 +249,8 @@ pub struct CompletedJob {
|
||||
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)]
|
||||
@@ -296,28 +289,38 @@ impl RunJobQuery {
|
||||
fn add_include_headers(
|
||||
&self,
|
||||
headers: HeaderMap,
|
||||
mut args: serde_json::Map<String, serde_json::Value>,
|
||||
args: serde_json::Map<String, serde_json::Value>,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let whitelist = self
|
||||
.include_header
|
||||
.as_ref()
|
||||
.map(|s| s.split(",").map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
whitelist
|
||||
.iter()
|
||||
.chain(INCLUDE_HEADERS.iter())
|
||||
.for_each(|h| {
|
||||
if let Some(v) = headers.get(h) {
|
||||
args.insert(
|
||||
h.to_string().to_lowercase().replace('-', "_"),
|
||||
serde_json::Value::String(v.to_str().unwrap().to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
args
|
||||
return add_include_headers(&self.include_header, headers, args);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_include_headers(
|
||||
include_header: &Option<String>,
|
||||
headers: HeaderMap,
|
||||
mut args: serde_json::Map<String, serde_json::Value>,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
if include_header.is_none() {
|
||||
return args;
|
||||
}
|
||||
let whitelist = include_header
|
||||
.as_ref()
|
||||
.map(|s| s.split(",").map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
whitelist
|
||||
.iter()
|
||||
.chain(INCLUDE_HEADERS.iter())
|
||||
.for_each(|h| {
|
||||
if let Some(v) = headers.get(h) {
|
||||
args.insert(
|
||||
h.to_string().to_lowercase().replace('-', "_"),
|
||||
serde_json::Value::String(v.to_str().unwrap().to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
args
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQueueQuery {
|
||||
pub script_path_start: Option<String>,
|
||||
@@ -472,7 +475,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",
|
||||
@@ -487,6 +490,7 @@ async fn list_jobs(
|
||||
"email",
|
||||
"visible_to_owner",
|
||||
"suspend",
|
||||
"mem_peak",
|
||||
],
|
||||
);
|
||||
let sqlc = list_completed_jobs_query(
|
||||
@@ -506,7 +510,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",
|
||||
@@ -521,6 +525,7 @@ async fn list_jobs(
|
||||
"email",
|
||||
"visible_to_owner",
|
||||
"null as suspend",
|
||||
"mem_peak",
|
||||
],
|
||||
);
|
||||
let sql = format!(
|
||||
@@ -921,16 +926,16 @@ pub async fn get_resume_urls(
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
) -> error::JsonResult<ResumeUrls> {
|
||||
let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).await?;
|
||||
let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?;
|
||||
let base_url = base_url.0.clone();
|
||||
let approver = approver
|
||||
.approver
|
||||
.as_ref()
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
let base_url = BASE_URL.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{job_id}/{resume_id}/{signature}{approver}"
|
||||
@@ -998,6 +1003,7 @@ struct UnifiedJob {
|
||||
email: String,
|
||||
visible_to_owner: bool,
|
||||
suspend: Option<i32>,
|
||||
mem_peak: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<UnifiedJob> for Job {
|
||||
@@ -1032,6 +1038,7 @@ impl From<UnifiedJob> for Job {
|
||||
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,
|
||||
@@ -1064,6 +1071,9 @@ impl From<UnifiedJob> for Job {
|
||||
email: uj.email,
|
||||
visible_to_owner: uj.visible_to_owner,
|
||||
suspend: uj.suspend,
|
||||
mem_peak: uj.mem_peak,
|
||||
root_job: None,
|
||||
leaf_jobs: None,
|
||||
}),
|
||||
t => panic!("job type {} not valid", t),
|
||||
}
|
||||
@@ -1159,6 +1169,7 @@ pub async fn run_flow_by_path(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1194,6 +1205,7 @@ pub async fn run_job_by_path(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1308,18 +1320,26 @@ pub async fn check_queue_too_long(db: DB, queue_limit: Option<i64>) -> error::Re
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref QUEUE_LIMIT_WAIT_RESULT: Option<i64> = std::env::var("QUEUE_LIMIT_WAIT_RESULT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok());
|
||||
pub static ref TIMEOUT_WAIT_RESULT: i32 = std::env::var("TIMEOUT_WAIT_RESULT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(20);
|
||||
}
|
||||
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)>,
|
||||
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?;
|
||||
check_queue_too_long(db, QUEUE_LIMIT_WAIT_RESULT.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?;
|
||||
@@ -1338,6 +1358,7 @@ pub async fn run_wait_result_job_by_path(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1349,7 +1370,7 @@ pub async fn run_wait_result_job_by_path(
|
||||
run_wait_result(
|
||||
authed,
|
||||
Extension(user_db),
|
||||
timeout.0,
|
||||
*TIMEOUT_WAIT_RESULT,
|
||||
uuid,
|
||||
Path((w_id, script_path)),
|
||||
)
|
||||
@@ -1360,7 +1381,6 @@ 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)>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
@@ -1385,6 +1405,7 @@ pub async fn run_wait_result_job_by_hash(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1396,7 +1417,7 @@ pub async fn run_wait_result_job_by_hash(
|
||||
run_wait_result(
|
||||
authed,
|
||||
Extension(user_db),
|
||||
timeout.0,
|
||||
*TIMEOUT_WAIT_RESULT,
|
||||
uuid,
|
||||
Path((w_id, script_hash)),
|
||||
)
|
||||
@@ -1407,7 +1428,6 @@ 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,
|
||||
@@ -1431,6 +1451,7 @@ pub async fn run_wait_result_flow_by_path(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1443,7 +1464,7 @@ pub async fn run_wait_result_flow_by_path(
|
||||
run_wait_result(
|
||||
authed,
|
||||
Extension(user_db),
|
||||
timeout.0,
|
||||
*TIMEOUT_WAIT_RESULT,
|
||||
uuid,
|
||||
Path((w_id, flow_path)),
|
||||
)
|
||||
@@ -1493,6 +1514,7 @@ async fn run_preview_job(
|
||||
scheduled_for,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1526,6 +1548,7 @@ async fn run_preview_flow_job(
|
||||
scheduled_for,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1561,6 +1584,7 @@ pub async fn run_job_by_hash(
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
run_query.parent_job,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -1582,6 +1606,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(
|
||||
@@ -1591,8 +1616,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
|
||||
@@ -1600,12 +1625,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!(
|
||||
@@ -1623,6 +1649,7 @@ async fn get_job_update(
|
||||
running: Some(false),
|
||||
completed: Some(true),
|
||||
new_logs: logs,
|
||||
mem_peak: record.map(|r| r.mem_peak).flatten(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1740,6 +1767,7 @@ async fn list_completed_jobs(
|
||||
"is_skipped",
|
||||
"email",
|
||||
"visible_to_owner",
|
||||
"mem_peak",
|
||||
],
|
||||
)
|
||||
.sql()?;
|
||||
@@ -1761,6 +1789,7 @@ async fn get_completed_job(
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
tracing::info!("job_o: {:?}", job_o);
|
||||
let job = not_found_if_none(job_o, "Completed Job", id.to_string())?;
|
||||
Ok(Json(job))
|
||||
}
|
||||
|
||||
@@ -6,21 +6,24 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::oauth2::AllClients;
|
||||
use argon2::Argon2;
|
||||
use axum::{middleware::from_extractor, routing::get, Extension, Router};
|
||||
use db::DB;
|
||||
use git_version::git_version;
|
||||
use reqwest::Client;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use windmill_common::{error::to_anyhow, utils::rd_string};
|
||||
use windmill_common::utils::rd_string;
|
||||
|
||||
use crate::{
|
||||
db::UserDB,
|
||||
oauth2::{build_oauth_clients, SlackVerifier},
|
||||
tracing_init::{MyMakeSpan, MyOnResponse},
|
||||
users::{Authed, OptAuthed},
|
||||
webhook_util::WebhookShared,
|
||||
};
|
||||
|
||||
mod apps;
|
||||
@@ -42,26 +45,39 @@ mod tracing_init;
|
||||
mod users;
|
||||
mod utils;
|
||||
mod variables;
|
||||
mod webhook_util;
|
||||
mod worker_ping;
|
||||
mod workspaces;
|
||||
|
||||
pub const GIT_VERSION: &str =
|
||||
git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
|
||||
|
||||
pub struct BaseUrl(String);
|
||||
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;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref BASE_URL: String = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
|
||||
|
||||
|
||||
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
|
||||
|
||||
pub static ref SLACK_SIGNING_SECRET: Option<SlackVerifier> = std::env::var("SLACK_SIGNING_SECRET")
|
||||
.ok()
|
||||
.map(|x| SlackVerifier::new(x).unwrap());
|
||||
|
||||
static ref IS_SECURE: bool = BASE_URL.starts_with("https://");
|
||||
|
||||
pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
|
||||
.user_agent("windmill/beta")
|
||||
.build().unwrap();
|
||||
|
||||
pub static ref OAUTH_CLIENTS: AllClients = build_oauth_clients(&BASE_URL)
|
||||
.map_err(|e| tracing::error!("Error building oauth clients: {}", e))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub async fn run_server(
|
||||
db: DB,
|
||||
addr: SocketAddr,
|
||||
base_url: String,
|
||||
mut rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let user_db = UserDB::new(db.clone());
|
||||
@@ -71,16 +87,7 @@ pub async fn run_server(
|
||||
std::env::var("SUPERADMIN_SECRET").ok(),
|
||||
));
|
||||
let argon2 = Arc::new(Argon2::default());
|
||||
let basic_clients = Arc::new(build_oauth_clients(&base_url).await?);
|
||||
let slack_verifier = Arc::new(
|
||||
std::env::var("SLACK_SIGNING_SECRET")
|
||||
.ok()
|
||||
.map(|x| SlackVerifier::new(x).unwrap()),
|
||||
);
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.user_agent("windmill/beta")
|
||||
.build()
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
let middleware_stack = ServiceBuilder::new()
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
@@ -91,22 +98,8 @@ pub async fn run_server(
|
||||
.layer(Extension(db.clone()))
|
||||
.layer(Extension(user_db))
|
||||
.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(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(
|
||||
@@ -116,21 +109,7 @@ pub async fn run_server(
|
||||
"/w/:workspace_id",
|
||||
Router::new()
|
||||
.nest("/scripts", scripts::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("/jobs", jobs::workspaced_service())
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
@@ -171,10 +150,7 @@ pub async fn run_server(
|
||||
"/auth",
|
||||
users::make_unauthed_service().layer(Extension(argon2)),
|
||||
)
|
||||
.nest(
|
||||
"/oauth",
|
||||
oauth2::global_service().layer(Extension(slack_verifier)),
|
||||
)
|
||||
.nest("/oauth", oauth2::global_service())
|
||||
.route("/version", get(git_v))
|
||||
.route("/openapi.yaml", get(openapi)),
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Ok;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
windmill_common::tracing_init::initialize_tracing();
|
||||
|
||||
let db = windmill_common::connect_db(true).await?;
|
||||
|
||||
let num_workers = std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i32>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_NUM_WORKERS as i32);
|
||||
|
||||
let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.parse::<bool>()
|
||||
.map(|b| b.then(|| SocketAddr::from(([0, 0, 0, 0], 8001))))
|
||||
.or_else(|_| s.parse::<SocketAddr>().map(Some))
|
||||
})
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
let server_mode = !std::env::var("DISABLE_SERVER")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if server_mode {
|
||||
windmill_api::migrate_db(&db).await?;
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||
let shutdown_signal = windmill_common::shutdown_signal(tx);
|
||||
|
||||
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
|
||||
|
||||
if server_mode || num_workers > 0 {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
|
||||
|
||||
let server_f = async {
|
||||
if server_mode {
|
||||
windmill_api::run_server(db.clone(), addr, base_url, rx.resubscribe()).await?;
|
||||
}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
let metrics_f = async {
|
||||
match metrics_addr {
|
||||
Some(addr) => windmill_common::serve_metrics(addr, rx.resubscribe())
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
futures::try_join!(shutdown_signal, server_f, metrics_f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
69
backend/windmill-api/src/main3.rs
Normal file
69
backend/windmill-api/src/main3.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
// /*
|
||||
// * Author: Ruben Fiszel
|
||||
// * Copyright: Windmill Labs, Inc 2022
|
||||
// * This file and its contents are licensed under the AGPLv3 License.
|
||||
// * Please see the included NOTICE for copyright information and
|
||||
// * LICENSE-AGPL for a copy of the license.
|
||||
// */
|
||||
// use std::net::SocketAddr;
|
||||
|
||||
// use anyhow::Ok;
|
||||
|
||||
// pub const DEFAULT_NUM_WORKERS: usize = 3;
|
||||
|
||||
// #[tokio::main]
|
||||
// async fn main() -> anyhow::Result<()> {
|
||||
// windmill_common::tracing_init::initialize_tracing();
|
||||
|
||||
// let db = windmill_common::connect_db(true).await?;
|
||||
|
||||
// let num_workers = std::env::var("NUM_WORKERS")
|
||||
// .ok()
|
||||
// .and_then(|x| x.parse::<i32>().ok())
|
||||
// .unwrap_or(DEFAULT_NUM_WORKERS as i32);
|
||||
|
||||
// let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
|
||||
// .ok()
|
||||
// .map(|s| {
|
||||
// s.parse::<bool>()
|
||||
// .map(|b| b.then(|| SocketAddr::from(([0, 0, 0, 0], 8001))))
|
||||
// .or_else(|_| s.parse::<SocketAddr>().map(Some))
|
||||
// })
|
||||
// .transpose()?
|
||||
// .flatten();
|
||||
|
||||
// let server_mode = !std::env::var("DISABLE_SERVER")
|
||||
// .ok()
|
||||
// .and_then(|x| x.parse::<bool>().ok())
|
||||
// .unwrap_or(false);
|
||||
|
||||
// if server_mode {
|
||||
// windmill_api::migrate_db(&db).await?;
|
||||
// }
|
||||
|
||||
// let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||
// let shutdown_signal = windmill_common::shutdown_signal(tx);
|
||||
|
||||
// if server_mode || num_workers > 0 {
|
||||
// let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
|
||||
|
||||
// let server_f = async {
|
||||
// if server_mode {
|
||||
// windmill_api::run_server(db.clone(), addr, rx.resubscribe()).await?;
|
||||
// }
|
||||
// Ok(()) as anyhow::Result<()>
|
||||
// };
|
||||
|
||||
// let metrics_f = async {
|
||||
// match metrics_addr {
|
||||
// Some(addr) => windmill_common::serve_metrics(addr, rx.resubscribe())
|
||||
// .await
|
||||
// .map_err(anyhow::Error::from),
|
||||
// None => Ok(()),
|
||||
// }
|
||||
// };
|
||||
|
||||
// futures::try_join!(shutdown_signal, server_f, metrics_f)?;
|
||||
// }
|
||||
// Ok(())
|
||||
// }
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
use std::{collections::HashMap, fmt::Debug};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
@@ -29,26 +27,25 @@ use oauth2::{Client as OClient, *};
|
||||
use reqwest::Client;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::utils::{not_found_if_none, now_from_db};
|
||||
|
||||
use crate::users::{truncate_token, Authed};
|
||||
use crate::users::{truncate_token, Authed, NEW_USER_WEBHOOK};
|
||||
use crate::workspaces::invite_user_to_all_auto_invite_worspaces;
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
variables::{build_crypt, encrypt},
|
||||
workspaces::WorkspaceSettings,
|
||||
BaseUrl,
|
||||
};
|
||||
use crate::{CookieDomain, IsSecure};
|
||||
use crate::{BASE_URL, HTTP_CLIENT, IS_SECURE, OAUTH_CLIENTS, SLACK_SIGNING_SECRET};
|
||||
use windmill_common::error::{self, to_anyhow, Error};
|
||||
use windmill_common::oauth2::*;
|
||||
|
||||
use windmill_queue::JobPayload;
|
||||
|
||||
use std::str;
|
||||
use std::{fs, str};
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -111,7 +108,7 @@ pub struct AllClients {
|
||||
pub slack: Option<OClient>,
|
||||
}
|
||||
|
||||
pub async fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
|
||||
pub fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
|
||||
let connect_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(include_str!(
|
||||
"../../oauth_connect.json"
|
||||
))?;
|
||||
@@ -119,14 +116,12 @@ pub async fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
|
||||
"../../oauth_login.json"
|
||||
))?;
|
||||
|
||||
let mut content = String::new();
|
||||
let path = "./oauth.json";
|
||||
if std::path::Path::new(path).exists() {
|
||||
let mut file = File::open(path).await?;
|
||||
file.read_to_string(&mut content).await?;
|
||||
let content = if std::path::Path::new(path).exists() {
|
||||
fs::read_to_string(path).map_err(to_anyhow)?
|
||||
} else {
|
||||
content.push_str("{}");
|
||||
}
|
||||
"{}".to_string()
|
||||
};
|
||||
|
||||
let oauths: HashMap<String, OAuthClient> =
|
||||
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
|
||||
@@ -288,12 +283,10 @@ pub struct SlackBotToken {
|
||||
async fn connect(
|
||||
Path(client_name): Path<String>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
cookies: Cookies,
|
||||
) -> error::Result<Redirect> {
|
||||
let mut query = query.clone();
|
||||
let connects = &clients.connects;
|
||||
let connects = &OAUTH_CLIENTS.connects;
|
||||
let scopes = query
|
||||
.get("scopes")
|
||||
.map(|x| x.split('+').map(|x| x.to_owned()).collect());
|
||||
@@ -309,7 +302,7 @@ async fn connect(
|
||||
cookies,
|
||||
scopes,
|
||||
extra_params,
|
||||
is_secure.0,
|
||||
*IS_SECURE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,11 +369,9 @@ async fn delete_account(
|
||||
Ok(format!("Deleted account id {id}"))
|
||||
}
|
||||
|
||||
async fn list_logins(
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
) -> error::JsonResult<Vec<String>> {
|
||||
async fn list_logins() -> error::JsonResult<Vec<String>> {
|
||||
Ok(Json(
|
||||
clients
|
||||
OAUTH_CLIENTS
|
||||
.logins
|
||||
.keys()
|
||||
.map(|x| x.to_owned())
|
||||
@@ -393,11 +384,9 @@ struct ScopesAndParams {
|
||||
scopes: Vec<String>,
|
||||
extra_params: Option<HashMap<String, String>>,
|
||||
}
|
||||
async fn list_connects(
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
) -> error::JsonResult<HashMap<String, ScopesAndParams>> {
|
||||
async fn list_connects() -> error::JsonResult<HashMap<String, ScopesAndParams>> {
|
||||
Ok(Json(
|
||||
(&clients.connects)
|
||||
(&OAUTH_CLIENTS.connects)
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
@@ -412,12 +401,8 @@ async fn list_connects(
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_slack(
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
cookies: Cookies,
|
||||
) -> error::Result<Redirect> {
|
||||
let mut client = clients
|
||||
async fn connect_slack(cookies: Cookies) -> error::Result<Redirect> {
|
||||
let mut client = OAUTH_CLIENTS
|
||||
.slack
|
||||
.as_ref()
|
||||
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
|
||||
@@ -428,7 +413,7 @@ async fn connect_slack(
|
||||
client.add_scope("commands");
|
||||
let url = client.authorize_url(&state);
|
||||
|
||||
set_cookie(&state, cookies, is_secure.0);
|
||||
set_cookie(&state, cookies, *IS_SECURE);
|
||||
Ok(Redirect::to(url.as_str()))
|
||||
}
|
||||
|
||||
@@ -470,14 +455,9 @@ async fn disconnect_slack(
|
||||
Ok(format!("slack disconnected"))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
Path(client_name): Path<String>,
|
||||
cookies: Cookies,
|
||||
) -> error::Result<Redirect> {
|
||||
let clients = &clients.logins;
|
||||
oauth_redirect(clients, client_name, cookies, None, None, is_secure.0)
|
||||
async fn login(Path(client_name): Path<String>, cookies: Cookies) -> error::Result<Redirect> {
|
||||
let clients = &OAUTH_CLIENTS.logins;
|
||||
oauth_redirect(clients, client_name, cookies, None, None, *IS_SECURE)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -488,13 +468,11 @@ async fn refresh_token(
|
||||
authed: Authed,
|
||||
Path((w_id, id)): Path<(String, i32)>,
|
||||
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?;
|
||||
|
||||
_refresh_token(tx, &path, w_id, id, clients, http_client).await?;
|
||||
_refresh_token(tx, &path, w_id, id).await?;
|
||||
|
||||
Ok(format!("Token at path {path} refreshed"))
|
||||
}
|
||||
@@ -504,8 +482,6 @@ pub async fn _refresh_token<'c>(
|
||||
path: &str,
|
||||
w_id: String,
|
||||
id: i32,
|
||||
clients: Arc<AllClients>,
|
||||
http_client: Client,
|
||||
) -> error::Result<String> {
|
||||
let account = sqlx::query!(
|
||||
"SELECT client, refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
@@ -515,14 +491,14 @@ pub async fn _refresh_token<'c>(
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
let account = not_found_if_none(account, "Account", &id.to_string())?;
|
||||
let client = (&clients
|
||||
let client = (&OAUTH_CLIENTS
|
||||
.connects
|
||||
.get(&account.client)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
||||
.client)
|
||||
.to_owned();
|
||||
|
||||
let token = _exchange_token(client, &account.refresh_token, http_client).await;
|
||||
let token = _exchange_token(client, &account.refresh_token).await;
|
||||
|
||||
if let Err(token_err) = token {
|
||||
sqlx::query!(
|
||||
@@ -580,14 +556,10 @@ pub async fn _refresh_token<'c>(
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
async fn _exchange_token(
|
||||
client: OClient,
|
||||
refresh_token: &str,
|
||||
http_client: Client,
|
||||
) -> Result<TokenResponse, Error> {
|
||||
async fn _exchange_token(client: OClient, refresh_token: &str) -> Result<TokenResponse, Error> {
|
||||
let token_json = client
|
||||
.exchange_refresh_token(&RefreshToken::from(refresh_token.clone()))
|
||||
.with_client(&http_client)
|
||||
.with_client(&HTTP_CLIENT)
|
||||
.execute::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
@@ -608,11 +580,9 @@ pub struct OAuthCallback {
|
||||
async fn connect_callback(
|
||||
cookies: Cookies,
|
||||
Path(client_name): Path<String>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
) -> error::JsonResult<TokenResponse> {
|
||||
let client_w_scopes = &clients
|
||||
let client_w_scopes = OAUTH_CLIENTS
|
||||
.connects
|
||||
.get(&client_name)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?;
|
||||
@@ -620,7 +590,7 @@ async fn connect_callback(
|
||||
let client = client_w_scopes.client.to_owned();
|
||||
let extra_params = client_w_scopes.extra_params_callback.clone();
|
||||
let token_response =
|
||||
exchange_code::<TokenResponse>(callback, &cookies, client, &http_client, extra_params)
|
||||
exchange_code::<TokenResponse>(callback, &cookies, client, &HTTP_CLIENT, extra_params)
|
||||
.await?;
|
||||
|
||||
Ok(Json(token_response))
|
||||
@@ -631,17 +601,15 @@ async fn connect_slack_callback(
|
||||
authed: Authed,
|
||||
cookies: Cookies,
|
||||
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
|
||||
let client = OAUTH_CLIENTS
|
||||
.slack
|
||||
.as_ref()
|
||||
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
|
||||
.to_owned();
|
||||
let token =
|
||||
exchange_code::<SlackTokenResponse>(callback, &cookies, client, &http_client, None).await?;
|
||||
exchange_code::<SlackTokenResponse>(callback, &cookies, client, &HTTP_CLIENT, None).await?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
@@ -657,14 +625,26 @@ async fn connect_slack_callback(
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query_as!(
|
||||
Group,
|
||||
"INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||||
w_id,
|
||||
"slack",
|
||||
"The group slack commands act on belhalf of",
|
||||
serde_json::json!({username_to_permissioned_as(&authed.username): true})
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO folder
|
||||
(workspace_id, name, owners, extra_perms)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||||
(workspace_id, name, display_name, owners, extra_perms)
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
||||
&w_id,
|
||||
"slack_bot",
|
||||
&[],
|
||||
serde_json::json!({})
|
||||
"Slack bot",
|
||||
&["g/slack".to_string()],
|
||||
serde_json::json!({"g/slack": true})
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
@@ -747,23 +727,17 @@ where
|
||||
|
||||
async fn slack_command(
|
||||
SlackSig { sig, ts }: SlackSig,
|
||||
Extension(slack_verifier): Extension<Arc<Option<SlackVerifier>>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
body: Bytes,
|
||||
) -> error::Result<String> {
|
||||
let form: SlackCommand = serde_urlencoded::from_bytes(&body)
|
||||
.map_err(|_| error::Error::BadRequest("invalid payload".to_string()))?;
|
||||
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
if slack_verifier
|
||||
.as_ref()
|
||||
.as_ref()
|
||||
.map(|sv| sv.verify(&ts, &body, &sig).ok())
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
return Err(error::Error::BadRequest("verification failed".to_owned()));
|
||||
if let Some(sv) = SLACK_SIGNING_SECRET.as_ref() {
|
||||
if sv.verify(&ts, &body, &sig).ok().is_none() {
|
||||
return Err(error::Error::BadRequest("verification failed".to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -807,6 +781,7 @@ async fn slack_command(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -814,7 +789,7 @@ async fn slack_command(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let url = base_url.0.to_owned();
|
||||
let url = BASE_URL.to_owned();
|
||||
return Ok(format!(
|
||||
"Job launched. See details at {url}/run/{uuid}?workspace={}",
|
||||
&settings.workspace_id
|
||||
@@ -839,31 +814,27 @@ pub struct UserInfo {
|
||||
async fn login_callback(
|
||||
Path(client_name): Path<String>,
|
||||
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
|
||||
let client_w_config = &OAUTH_CLIENTS
|
||||
.logins
|
||||
.get(&client_name)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?;
|
||||
let client = client_w_config.client.to_owned();
|
||||
let token_res =
|
||||
exchange_code::<TokenResponse>(callback, &cookies, client, &http_client, None).await;
|
||||
exchange_code::<TokenResponse>(callback, &cookies, client, &HTTP_CLIENT, None).await;
|
||||
|
||||
if let Ok(token) = token_res {
|
||||
let token = &token.access_token.to_string();
|
||||
let userinfo_url = client_w_config.userinfo_url.as_ref().ok_or_else(|| {
|
||||
Error::BadConfig(format!("Missing userinfo_url in client {client_name}"))
|
||||
})?;
|
||||
let user = http_get_user_info::<UserInfo>(&http_client, userinfo_url, token).await?;
|
||||
let user = http_get_user_info::<UserInfo>(&HTTP_CLIENT, userinfo_url, token).await?;
|
||||
|
||||
let email = match client_name.as_str() {
|
||||
"github" => http_get_user_info::<Vec<GHEmailInfo>>(
|
||||
&http_client,
|
||||
&HTTP_CLIENT,
|
||||
"https://api.github.com/user/emails",
|
||||
token,
|
||||
)
|
||||
@@ -899,15 +870,7 @@ async fn login_callback(
|
||||
if let Some((email, login_type, super_admin)) = login {
|
||||
let login_type = serde_json::json!(login_type);
|
||||
if login_type == client_name {
|
||||
crate::users::create_session_token(
|
||||
&email,
|
||||
super_admin,
|
||||
&mut tx,
|
||||
cookies,
|
||||
is_secure.0,
|
||||
&cookie_domain.as_ref().0,
|
||||
)
|
||||
.await?;
|
||||
crate::users::create_session_token(&email, super_admin, &mut tx, cookies).await?;
|
||||
} else {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"an user with the email associated to this login exists but with a different \
|
||||
@@ -942,15 +905,7 @@ async fn login_callback(
|
||||
tx.commit().await?;
|
||||
invite_user_to_all_auto_invite_worspaces(&db, &email).await?;
|
||||
tx = db.begin().await?;
|
||||
crate::users::create_session_token(
|
||||
&email,
|
||||
false,
|
||||
&mut tx,
|
||||
cookies,
|
||||
is_secure.0,
|
||||
&cookie_domain.as_ref().0,
|
||||
)
|
||||
.await?;
|
||||
crate::users::create_session_token(&email, false, &mut tx, cookies).await?;
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&email,
|
||||
@@ -961,6 +916,7 @@ async fn login_callback(
|
||||
Some([("method", &client_name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let demo_exists =
|
||||
sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'demo')")
|
||||
.fetch_one(&mut tx)
|
||||
@@ -982,6 +938,16 @@ async fn login_callback(
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(new_user_webhook) = NEW_USER_WEBHOOK.clone() {
|
||||
let _ = HTTP_CLIENT
|
||||
.post(&new_user_webhook)
|
||||
.json(&serde_json::json!({"email" : &email, "event": "oauth_signup"}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| tracing::error!("Error sending new user webhook: {}", e.to_string()));
|
||||
}
|
||||
|
||||
Ok("Successfully logged in".to_string())
|
||||
} else {
|
||||
Err(error::Error::BadRequest(format!(
|
||||
@@ -1103,7 +1069,7 @@ fn set_cookie(state: &State, cookies: Cookies, is_secure: bool) {
|
||||
let csrf = state.to_base64();
|
||||
let mut cookie = Cookie::new("csrf", csrf);
|
||||
cookie.set_secure(is_secure);
|
||||
cookie.set_same_site(cookie::SameSite::Lax);
|
||||
cookie.set_same_site(Some(cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_path("/");
|
||||
cookies.add(cookie);
|
||||
|
||||
@@ -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},
|
||||
@@ -141,7 +142,7 @@ async fn list_resources(
|
||||
.join("account")
|
||||
.on("variable.account = account.id AND account.workspace_id = variable.workspace_id")
|
||||
.order_by("path", true)
|
||||
.and_where("resource.workspace_id = ? OR resource.workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("resource.workspace_id = ?".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -186,7 +187,7 @@ async fn get_resource(
|
||||
FROM resource
|
||||
LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = resource.workspace_id
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = resource.workspace_id
|
||||
WHERE resource.path = $1 AND (resource.workspace_id = $2 OR resource.workspace_id = 'starter')",
|
||||
WHERE resource.path = $1 AND resource.workspace_id = $2",
|
||||
path.to_owned(),
|
||||
&w_id
|
||||
)
|
||||
@@ -225,8 +226,7 @@ async fn get_resource_value(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let value_o = sqlx::query_scalar!(
|
||||
"SELECT value from resource WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter')",
|
||||
"SELECT value from resource WHERE path = $1 AND workspace_id = $2",
|
||||
path.to_owned(),
|
||||
&w_id
|
||||
)
|
||||
@@ -263,6 +263,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 +294,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 +308,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 +340,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,6 +413,15 @@ 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))
|
||||
}
|
||||
|
||||
@@ -411,6 +433,7 @@ struct UpdateResource {
|
||||
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<UpdateResource>,
|
||||
) -> Result<String> {
|
||||
@@ -436,6 +459,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))
|
||||
}
|
||||
@@ -446,7 +477,7 @@ async fn list_resource_types(
|
||||
) -> JsonResult<Vec<ResourceType>> {
|
||||
let rows = sqlx::query_as!(
|
||||
ResourceType,
|
||||
"SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') ORDER \
|
||||
"SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \
|
||||
BY name",
|
||||
&w_id
|
||||
)
|
||||
@@ -461,7 +492,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' OR workspace_id = 'admins') \
|
||||
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') \
|
||||
ORDER BY name",
|
||||
&w_id
|
||||
)
|
||||
@@ -480,8 +511,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' OR workspace_id = 'admins')",
|
||||
"SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
|
||||
&name,
|
||||
&w_id
|
||||
)
|
||||
@@ -498,8 +528,7 @@ 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 OR workspace_id = \
|
||||
'starter' OR workspace_id = 'admins'))",
|
||||
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins'))",
|
||||
name,
|
||||
w_id
|
||||
)
|
||||
@@ -513,6 +542,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)> {
|
||||
@@ -543,6 +573,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),
|
||||
@@ -574,6 +609,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)?;
|
||||
@@ -598,6 +634,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))
|
||||
}
|
||||
@@ -605,6 +645,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> {
|
||||
@@ -634,6 +675,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))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use reqwest::Client;
|
||||
use sql_builder::prelude::*;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
|
||||
@@ -14,6 +13,8 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
schedule::clear_schedule,
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -69,6 +70,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/exists/p/*path", get(exists_script_by_path))
|
||||
.route("/archive/h/:hash", post(archive_script_by_hash))
|
||||
.route("/delete/h/:hash", post(delete_script_by_hash))
|
||||
.route("/delete/p/*path", post(delete_script_by_path))
|
||||
.route("/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))
|
||||
@@ -110,7 +112,7 @@ async fn list_scripts(
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("o.workspace_id = ?".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -119,9 +121,10 @@ async fn list_scripts(
|
||||
sqlb.and_where_eq(
|
||||
"created_at",
|
||||
"(select max(created_at) from script where o.path = path
|
||||
AND (workspace_id = ? OR workspace_id = 'starter'))"
|
||||
AND workspace_id = ?)"
|
||||
.bind(&w_id),
|
||||
);
|
||||
sqlb.and_where_eq("archived", true);
|
||||
} else {
|
||||
sqlb.and_where_eq("archived", false);
|
||||
}
|
||||
@@ -162,12 +165,9 @@ async fn list_scripts(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
async fn list_hub_scripts(
|
||||
Authed { email, .. }: Authed,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
async fn list_hub_scripts(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
|
||||
let asks = list_elems_from_hub(
|
||||
http_client,
|
||||
&HTTP_CLIENT,
|
||||
"https://hub.windmill.dev/searchData?approved=true",
|
||||
&email,
|
||||
)
|
||||
@@ -184,6 +184,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>,
|
||||
@@ -378,6 +379,7 @@ async fn create_script(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
@@ -400,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,
|
||||
@@ -417,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?;
|
||||
@@ -424,21 +442,16 @@ async fn create_script(
|
||||
Ok((StatusCode::CREATED, format!("{}", hash)))
|
||||
}
|
||||
|
||||
pub async fn get_hub_script_by_path(
|
||||
authed: Authed,
|
||||
Path(path): Path<StripPath>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> Result<String> {
|
||||
windmill_common::scripts::get_hub_script_by_path(&authed.email, path, http_client).await
|
||||
pub async fn get_hub_script_by_path(authed: Authed, Path(path): Path<StripPath>) -> Result<String> {
|
||||
windmill_common::scripts::get_hub_script_by_path(&authed.email, path, &HTTP_CLIENT).await
|
||||
}
|
||||
|
||||
pub async fn get_full_hub_script_by_path(
|
||||
Authed { email, .. }: Authed,
|
||||
Path(path): Path<StripPath>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<HubScript> {
|
||||
Ok(Json(
|
||||
windmill_common::scripts::get_full_hub_script_by_path(&email, path, http_client).await?,
|
||||
windmill_common::scripts::get_full_hub_script_by_path(&email, path, &HTTP_CLIENT).await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -451,9 +464,9 @@ async fn get_script_by_path(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script_o = sqlx::query_as::<_, Script>(
|
||||
"SELECT * FROM script WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter') \
|
||||
"SELECT * FROM script WHERE path = $1 AND workspace_id = $2 \
|
||||
AND created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND \
|
||||
(workspace_id = $2 OR workspace_id = 'starter'))",
|
||||
workspace_id = $2)",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
@@ -492,11 +505,12 @@ async fn raw_script_by_path(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let content_o = sqlx::query_scalar!(
|
||||
"SELECT content FROM script WHERE path = $1 AND (workspace_id = $2 OR workspace_id = 'starter') \
|
||||
"SELECT content FROM script WHERE path = $1 AND workspace_id = $2 \
|
||||
AND
|
||||
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND archived = false AND \
|
||||
(workspace_id = $2 OR workspace_id = 'starter'))",
|
||||
path, w_id
|
||||
workspace_id = $2)",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
@@ -513,10 +527,8 @@ async fn exists_script_by_path(
|
||||
let path = path.to_path();
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND (workspace_id = $2 OR \
|
||||
workspace_id = 'starter') AND
|
||||
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND (workspace_id = $2 \
|
||||
OR workspace_id = 'starter')))",
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND
|
||||
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND workspace_id = $2))",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
@@ -532,13 +544,12 @@ async fn get_script_by_hash_internal<'c>(
|
||||
workspace_id: &str,
|
||||
hash: &ScriptHash,
|
||||
) -> Result<Script> {
|
||||
let script_o = sqlx::query_as::<_, Script>(
|
||||
"SELECT * FROM script WHERE hash = $1 AND (workspace_id = $2 OR workspace_id = 'starter')",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let script_o =
|
||||
sqlx::query_as::<_, Script>("SELECT * FROM script WHERE hash = $1 AND workspace_id = $2")
|
||||
.bind(hash)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let script = not_found_if_none(script_o, "Script", hash.to_string())?;
|
||||
Ok(script)
|
||||
@@ -584,8 +595,7 @@ async fn get_deployment_status(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let status_o: Option<DeploymentStatus> = sqlx::query_as!(
|
||||
DeploymentStatus,
|
||||
"SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND (workspace_id = $2 OR \
|
||||
workspace_id = 'starter')",
|
||||
"SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
hash.0,
|
||||
w_id,
|
||||
)
|
||||
@@ -600,6 +610,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)>,
|
||||
@@ -626,6 +637,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(())
|
||||
}
|
||||
@@ -633,6 +648,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?;
|
||||
@@ -657,12 +673,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> {
|
||||
@@ -691,6 +713,51 @@ 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))
|
||||
}
|
||||
|
||||
async fn delete_script_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let path = path.to_path();
|
||||
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let script = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("deleting script by path {w_id}: {e}")))?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
"scripts.delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&path),
|
||||
Some([("workspace", w_id.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
WebhookMessage::DeleteScriptPath { workspace: w_id, path: path.to_string() },
|
||||
);
|
||||
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,58 +9,41 @@
|
||||
use axum::{
|
||||
body::{self, BoxBody},
|
||||
extract::OriginalUri,
|
||||
http::{header, response::Builder, Response},
|
||||
http::{header, Response},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
|
||||
use crate::{CloudHosted, ContentSecurityPolicy, IsSecure};
|
||||
use hyper::Uri;
|
||||
use mime_guess::mime;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::sync::Arc;
|
||||
|
||||
// static_handler is a handler that serves static files from the
|
||||
pub async fn static_handler(
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
Extension(is_cloud_hosted): Extension<Arc<CloudHosted>>,
|
||||
Extension(csp): Extension<Arc<ContentSecurityPolicy>>,
|
||||
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)
|
||||
pub async fn static_handler(OriginalUri(original_uri): OriginalUri) -> StaticFile {
|
||||
StaticFile(original_uri)
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../frontend/build/"]
|
||||
struct Asset;
|
||||
pub struct StaticFile(
|
||||
pub String,
|
||||
pub bool,
|
||||
pub bool,
|
||||
pub Arc<ContentSecurityPolicy>,
|
||||
);
|
||||
pub struct StaticFile(Uri);
|
||||
|
||||
impl IntoResponse for StaticFile {
|
||||
fn into_response(self) -> Response<BoxBody> {
|
||||
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)
|
||||
let path = self.0.path().trim_start_matches('/');
|
||||
serve_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_path(
|
||||
path: String,
|
||||
can_set_security_headers: bool,
|
||||
csp: Arc<ContentSecurityPolicy>,
|
||||
) -> Response<BoxBody> {
|
||||
const TWO_HUNDRED: &str = "200.html";
|
||||
|
||||
fn serve_path(path: &str) -> Response<BoxBody> {
|
||||
if path.starts_with("api/") {
|
||||
return Response::builder()
|
||||
.status(404)
|
||||
.body(body::boxed(body::Empty::new()))
|
||||
.unwrap();
|
||||
}
|
||||
match Asset::get(path.as_str()) {
|
||||
match Asset::get(path) {
|
||||
Some(content) => {
|
||||
let body = body::boxed(body::Full::from(content.data));
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
@@ -75,26 +58,12 @@ fn serve_path(
|
||||
res = res.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate");
|
||||
}
|
||||
|
||||
if can_set_security_headers {
|
||||
res = set_security_headers(res, csp);
|
||||
}
|
||||
res.body(body).unwrap()
|
||||
}
|
||||
None if path.as_str().starts_with("_app/") => Response::builder()
|
||||
None if path.starts_with("_app/") => Response::builder()
|
||||
.status(404)
|
||||
.body(body::boxed(body::Empty::new()))
|
||||
.unwrap(),
|
||||
None => serve_path("200.html".to_owned(), can_set_security_headers, csp),
|
||||
None => serve_path(TWO_HUNDRED),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_security_headers(mut res: Builder, csp: Arc<ContentSecurityPolicy>) -> Builder {
|
||||
res = res.header("X-Frame-Options", "DENY");
|
||||
res = res.header("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if !csp.0.is_empty() {
|
||||
res = res.header("Content-Security-Policy", &csp.0);
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
folders::get_folders_for_user,
|
||||
utils::require_super_admin,
|
||||
workspaces::invite_user_to_all_auto_invite_worspaces,
|
||||
CookieDomain, IsSecure,
|
||||
COOKIE_DOMAIN, HTTP_CLIENT, IS_SECURE,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use axum::{
|
||||
@@ -720,14 +720,12 @@ async fn logout(
|
||||
Tokened { token }: Tokened,
|
||||
cookies: Cookies,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(cookie_domain): Extension<Arc<CookieDomain>>,
|
||||
Query(LogoutQuery { rd }): Query<LogoutQuery>,
|
||||
) -> Result<Response> {
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, "");
|
||||
cookie.set_path(COOKIE_PATH);
|
||||
let domain = cookie_domain.0.clone();
|
||||
if domain.is_some() {
|
||||
cookie.set_domain(domain.clone().unwrap());
|
||||
if COOKIE_DOMAIN.is_some() {
|
||||
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
|
||||
}
|
||||
cookies.remove(cookie);
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -1270,6 +1268,10 @@ async fn delete_user(
|
||||
Ok(format!("email {} deleted", &email_to_delete))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref NEW_USER_WEBHOOK: Option<String> = std::env::var("NEW_USER_WEBHOOK").ok();
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
Authed { email, .. }: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1305,6 +1307,18 @@ async fn create_user(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(new_user_webhook) = NEW_USER_WEBHOOK.clone() {
|
||||
let _ = HTTP_CLIENT
|
||||
.post(&new_user_webhook)
|
||||
.json(
|
||||
&serde_json::json!({"email" : &nu.email, "name": &nu.name, "event": "global_add"}),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| tracing::error!("Error sending new user webhook: {}", e.to_string()));
|
||||
}
|
||||
|
||||
invite_user_to_all_auto_invite_worspaces(&db, &nu.email).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("email {} created", nu.email)))
|
||||
@@ -1549,21 +1563,19 @@ async fn login(
|
||||
cookies: Cookies,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
Extension(cookie_domain): Extension<Arc<CookieDomain>>,
|
||||
Json(Login { email, password }): Json<Login>,
|
||||
) -> Result<String> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let email_w_h: Option<(String, String, bool)> = sqlx::query_as(
|
||||
"SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \
|
||||
let email_w_h: Option<(String, String, bool, bool)> = sqlx::query_as(
|
||||
"SELECT email, password_hash, super_admin, first_time_user FROM password WHERE email = $1 AND login_type = \
|
||||
'password'",
|
||||
)
|
||||
.bind(&email)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
if let Some((email, hash, super_admin)) = email_w_h {
|
||||
if let Some((email, hash, super_admin, first_time_user)) = email_w_h {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(&hash).map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
if argon2
|
||||
@@ -1572,15 +1584,27 @@ async fn login(
|
||||
{
|
||||
Err(Error::BadRequest("Invalid login".to_string()))
|
||||
} else {
|
||||
let token = create_session_token(
|
||||
&email,
|
||||
super_admin,
|
||||
&mut tx,
|
||||
cookies,
|
||||
is_secure.0,
|
||||
&cookie_domain.as_ref().0,
|
||||
)
|
||||
.await?;
|
||||
if first_time_user {
|
||||
sqlx::query_scalar!(
|
||||
"UPDATE password SET first_time_user = false WHERE email = $1",
|
||||
&email
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
let mut c = Cookie::new("first_time", "1");
|
||||
if let Some(domain) = COOKIE_DOMAIN.as_ref() {
|
||||
c.set_domain(domain);
|
||||
}
|
||||
c.set_secure(false);
|
||||
c.set_expires(time::OffsetDateTime::now_utc() + time::Duration::minutes(15));
|
||||
c.set_http_only(false);
|
||||
c.set_path("/");
|
||||
|
||||
cookies.add(c);
|
||||
}
|
||||
|
||||
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(token)
|
||||
}
|
||||
@@ -1594,8 +1618,6 @@ pub async fn create_session_token<'c>(
|
||||
super_admin: bool,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
cookies: Cookies,
|
||||
is_secure: bool,
|
||||
domain: &Option<String>,
|
||||
) -> Result<String> {
|
||||
let token = rd_string(30);
|
||||
sqlx::query!(
|
||||
@@ -1611,12 +1633,12 @@ pub async fn create_session_token<'c>(
|
||||
.execute(tx)
|
||||
.await?;
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
|
||||
cookie.set_secure(is_secure);
|
||||
cookie.set_same_site(cookie::SameSite::Lax);
|
||||
cookie.set_secure(*IS_SECURE);
|
||||
cookie.set_same_site(Some(cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_path(COOKIE_PATH);
|
||||
if domain.is_some() {
|
||||
cookie.set_domain(domain.clone().unwrap());
|
||||
if COOKIE_DOMAIN.is_some() {
|
||||
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
|
||||
}
|
||||
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
|
||||
expire += time::Duration::days(3);
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
oauth2::{AllClients, _refresh_token},
|
||||
oauth2::_refresh_token,
|
||||
users::{require_owner_of_path, Authed},
|
||||
BaseUrl,
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
};
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
@@ -36,7 +34,6 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
|
||||
@@ -53,7 +50,6 @@ pub fn workspaced_service() -> Router {
|
||||
|
||||
async fn list_contextual_variables(
|
||||
Path(w_id): Path<String>,
|
||||
Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
Authed { username, email, .. }: Authed,
|
||||
) -> JsonResult<Vec<ContextualVariable>> {
|
||||
Ok(Json(
|
||||
@@ -64,7 +60,6 @@ async fn list_contextual_variables(
|
||||
&username,
|
||||
"017e0ad5-f499-73b6-5488-92a61c5196dd",
|
||||
format!("u/{username}").as_str(),
|
||||
&base_url.0,
|
||||
Some("u/user/script_path".to_string()),
|
||||
Some("017e0ad5-f499-73b6-5488-92a61c5196dd".to_string()),
|
||||
Some("u/user/encapsulating_flow_path".to_string()),
|
||||
@@ -90,7 +85,7 @@ async fn list_variables(
|
||||
from variable
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = variable.workspace_id
|
||||
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = variable.workspace_id
|
||||
WHERE variable.workspace_id = $1 OR (is_secret IS NOT TRUE AND variable.workspace_id = 'starter') ORDER BY path",
|
||||
WHERE variable.workspace_id = $1 ORDER BY path",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut tx)
|
||||
@@ -110,8 +105,6 @@ async fn get_variable(
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Query(q): Query<GetVariableQuery>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<ListableVariable> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -123,8 +116,7 @@ async fn get_variable(
|
||||
from variable
|
||||
LEFT JOIN account ON variable.account = account.id
|
||||
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = variable.workspace_id
|
||||
WHERE variable.path = $1 AND (variable.workspace_id = $2 OR (is_secret IS NOT TRUE AND \
|
||||
variable.workspace_id = 'starter'))
|
||||
WHERE variable.path = $1 AND variable.workspace_id = $2
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&path)
|
||||
@@ -150,17 +142,7 @@ async fn get_variable(
|
||||
let value = variable.value.unwrap_or_else(|| "".to_string());
|
||||
ListableVariable {
|
||||
value: if variable.is_expired.unwrap_or(false) && variable.account.is_some() {
|
||||
Some(
|
||||
_refresh_token(
|
||||
tx,
|
||||
&variable.path,
|
||||
w_id,
|
||||
variable.account.unwrap(),
|
||||
clients,
|
||||
http_client,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
Some(_refresh_token(tx, &variable.path, w_id, variable.account.unwrap()).await?)
|
||||
} else if !value.is_empty() && decrypt_secret {
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
tx.commit().await?;
|
||||
@@ -224,13 +206,15 @@ 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>,
|
||||
Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>,
|
||||
Json(variable): Json<CreateVariable>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
check_path_conflict(&mut tx, &w_id, &variable.path).await?;
|
||||
let value = if variable.is_secret {
|
||||
let value = if variable.is_secret && !already_encrypted.unwrap_or(false) {
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
encrypt(&mc, &variable.value)
|
||||
} else {
|
||||
@@ -265,6 +249,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 +263,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 +296,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))
|
||||
}
|
||||
|
||||
@@ -317,11 +312,18 @@ struct EditVariable {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AlreadyEncrypted {
|
||||
already_encrypted: Option<bool>,
|
||||
}
|
||||
|
||||
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)>,
|
||||
Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>,
|
||||
Json(ns): Json<EditVariable>,
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
@@ -347,7 +349,7 @@ async fn update_variable(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let value = if is_secret {
|
||||
let value = if is_secret && !already_encrypted.unwrap_or(false) {
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
encrypt(&mc, &nvalue)
|
||||
} else {
|
||||
@@ -405,6 +407,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))
|
||||
}
|
||||
|
||||
|
||||
114
backend/windmill-api/src/webhook_util.rs
Normal file
114
backend/windmill-api/src/webhook_util.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
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 },
|
||||
DeleteFlow { 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 },
|
||||
DeleteScriptPath { workspace: String, path: 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));
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,31 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use std::str::FromStr;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use crate::BASE_URL;
|
||||
use crate::{
|
||||
apps::AppWithLastVersion,
|
||||
db::{UserDB, DB},
|
||||
folders::Folder,
|
||||
resources::{Resource, ResourceType},
|
||||
users::{Authed, WorkspaceInvite},
|
||||
users::{Authed, WorkspaceInvite, NEW_USER_WEBHOOK},
|
||||
utils::require_super_admin,
|
||||
BaseUrl,
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
body::StreamBody,
|
||||
extract::{Extension, Path, Query},
|
||||
headers,
|
||||
response::{IntoResponse, Redirect},
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use stripe::CustomerId;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
@@ -42,7 +49,7 @@ use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
let router = Router::new()
|
||||
.route("/list_pending_invites", get(list_pending_invites))
|
||||
.route("/update", post(edit_workspace))
|
||||
.route("/archive", post(archive_workspace))
|
||||
@@ -51,11 +58,20 @@ pub fn workspaced_service() -> Router {
|
||||
.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("/premium_info", get(premium_info));
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
tracing::info!("stripe enabled");
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let router = router
|
||||
.route("/checkout", get(stripe_checkout))
|
||||
.route("/billing_portal", get(stripe_portal))
|
||||
.route("/billing_portal", get(stripe_portal));
|
||||
|
||||
router
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -90,6 +106,7 @@ pub struct WorkspaceSettings {
|
||||
pub auto_invite_operator: Option<bool>,
|
||||
pub customer_id: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub webhook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Debug)]
|
||||
@@ -117,6 +134,11 @@ struct EditAutoInvite {
|
||||
operator: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditWebhook {
|
||||
webhook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateWorkspace {
|
||||
id: String,
|
||||
@@ -209,24 +231,25 @@ async fn premium_info(
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize)]
|
||||
struct PlanQuery {
|
||||
plan: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
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 success_rd = format!("{}/workspace_settings/checkout?success=true", *BASE_URL);
|
||||
let failure_rd = format!("{}/workspace_settings/checkout?success=false", *BASE_URL);
|
||||
let checkout_session = {
|
||||
let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd);
|
||||
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
|
||||
@@ -280,11 +303,11 @@ async fn stripe_checkout(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
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!(
|
||||
@@ -295,7 +318,7 @@ async fn stripe_portal(
|
||||
.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 success_rd = format!("{}/workspace_settings?tab=premium", *BASE_URL);
|
||||
let portal_session = {
|
||||
let customer_id = CustomerId::from_str(&customer_id).unwrap();
|
||||
let mut params = stripe::CreateBillingPortalSession::new(customer_id);
|
||||
@@ -519,6 +542,48 @@ async fn edit_auto_invite(
|
||||
))
|
||||
}
|
||||
|
||||
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(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -612,19 +677,19 @@ async fn create_workspace(
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
let mc = magic_crypt::new_magic_crypt!(key, 256);
|
||||
sqlx::query!(
|
||||
"INSERT INTO variable
|
||||
(workspace_id, path, value, is_secret, description)
|
||||
VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
|
||||
($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
|
||||
nw.id,
|
||||
crate::variables::encrypt(&mc, "pretty secret value"),
|
||||
nw.id,
|
||||
"finland does not actually exist",
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
// let mc = magic_crypt::new_magic_crypt!(key, 256);
|
||||
// sqlx::query!(
|
||||
// "INSERT INTO variable
|
||||
// (workspace_id, path, value, is_secret, description)
|
||||
// VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
|
||||
// ($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
|
||||
// nw.id,
|
||||
// crate::variables::encrypt(&mc, "pretty secret value"),
|
||||
// nw.id,
|
||||
// "finland does not actually exist",
|
||||
// )
|
||||
// .execute(&mut tx)
|
||||
// .await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr
|
||||
@@ -897,6 +962,15 @@ async fn invite_user(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(new_user_webhook) = NEW_USER_WEBHOOK.clone() {
|
||||
let _ = &HTTP_CLIENT
|
||||
.post(&new_user_webhook)
|
||||
.json(&serde_json::json!({"email" : &nu.email, "event": "workspace_invite"}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| tracing::error!("Error sending new user webhook: {}", e.to_string()));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("user with email {} invited", nu.email),
|
||||
@@ -928,6 +1002,15 @@ async fn add_user(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(new_user_webhook) = NEW_USER_WEBHOOK.clone() {
|
||||
let _ = HTTP_CLIENT
|
||||
.post(&new_user_webhook)
|
||||
.json(&serde_json::json!({"email" : &nu.email, "event": "workspace_add"}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| tracing::error!("Error sending new user webhook: {}", e.to_string()));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("user with email {} added", nu.email),
|
||||
@@ -992,20 +1075,118 @@ struct ScriptMetadata {
|
||||
lock: Vec<String>,
|
||||
}
|
||||
|
||||
enum ArchiveImpl {
|
||||
Zip(async_zip::write::ZipFileWriter<File>),
|
||||
Tar(tokio_tar::Builder<File>),
|
||||
}
|
||||
|
||||
impl ArchiveImpl {
|
||||
async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> {
|
||||
match self {
|
||||
ArchiveImpl::Tar(t) => {
|
||||
let bytes = content.as_bytes();
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mtime(0);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mode(0o777);
|
||||
header.set_cksum();
|
||||
t.append_data(&mut header, path, bytes).await?;
|
||||
}
|
||||
ArchiveImpl::Zip(z) => {
|
||||
let header = async_zip::ZipEntryBuilder::new(
|
||||
path.to_owned(),
|
||||
async_zip::Compression::Deflate,
|
||||
)
|
||||
.last_modification_date(Default::default())
|
||||
.unix_permissions(0o777)
|
||||
.build();
|
||||
z.write_entry_whole(header, content.as_bytes())
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn finish(self) -> Result<()> {
|
||||
match self {
|
||||
ArchiveImpl::Tar(t) => t.into_inner().await?,
|
||||
ArchiveImpl::Zip(z) => z.close().await.map_err(to_anyhow)?,
|
||||
}
|
||||
.sync_all()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ArchiveQueryParams {
|
||||
archive_type: Option<String>,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn to_string_without_metadata<T>(value: &T, preserve_extra_perms: bool) -> Result<String>
|
||||
where
|
||||
T: ?Sized + Serialize,
|
||||
{
|
||||
let value = serde_json::to_value(value).map_err(to_anyhow)?;
|
||||
value
|
||||
.as_object()
|
||||
.map(|obj| {
|
||||
let mut obj = obj.clone();
|
||||
for key in [
|
||||
"workspace_id",
|
||||
"path",
|
||||
"name",
|
||||
"versions",
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"edited_at",
|
||||
"edited_by",
|
||||
"archived",
|
||||
] {
|
||||
if obj.contains_key(key) {
|
||||
obj.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
if !preserve_extra_perms && obj.contains_key("extra_perms") {
|
||||
obj.remove("extra_perms");
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&obj).ok()
|
||||
})
|
||||
.flatten()
|
||||
.ok_or_else(|| Error::BadRequest("Impossible to serialize value".to_string()))
|
||||
}
|
||||
|
||||
async fn tarball_workspace(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(ArchiveQueryParams { archive_type }): Query<ArchiveQueryParams>,
|
||||
) -> Result<([(headers::HeaderName, String); 2], impl IntoResponse)> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let tmp_dir = TempDir::new_in(".")?;
|
||||
|
||||
let name = format!("windmill-{w_id}.tar");
|
||||
let name = match archive_type.as_deref() {
|
||||
Some("tar") | None => Ok(format!("windmill-{w_id}.tar")),
|
||||
Some("zip") => Ok(format!("windmill-{w_id}.zip")),
|
||||
Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))),
|
||||
}?;
|
||||
let file_path = tmp_dir.path().join(&name);
|
||||
let file = File::create(&file_path).await?;
|
||||
let mut a = tokio_tar::Builder::new(file);
|
||||
|
||||
let mut archive = match archive_type.as_deref() {
|
||||
Some("tar") | None => Ok(ArchiveImpl::Tar(tokio_tar::Builder::new(file))),
|
||||
Some("zip") => Ok(ArchiveImpl::Zip(async_zip::write::ZipFileWriter::new(file))),
|
||||
Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))),
|
||||
}?;
|
||||
{
|
||||
let folders = sqlx::query_as::<_, Folder>("SELECT * FROM folder WHERE workspace_id = $1")
|
||||
.bind(&w_id)
|
||||
@@ -1013,12 +1194,12 @@ async fn tarball_workspace(
|
||||
.await?;
|
||||
|
||||
for folder in folders {
|
||||
write_to_archive(
|
||||
serde_json::to_string_pretty(&folder).unwrap(),
|
||||
format!("f/{}/folder.meta.json", folder.name),
|
||||
&mut a,
|
||||
)
|
||||
.await?;
|
||||
archive
|
||||
.write_to_archive(
|
||||
&to_string_without_metadata(&folder, true).unwrap(),
|
||||
&format!("f/{}/folder.meta.json", folder.name),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,7 +1220,9 @@ async fn tarball_workspace(
|
||||
ScriptLang::Go => "go",
|
||||
ScriptLang::Bash => "sh",
|
||||
};
|
||||
write_to_archive(script.content, format!("{}.{}", script.path, ext), &mut a).await?;
|
||||
archive
|
||||
.write_to_archive(&script.content, &format!("{}.{}", script.path, ext))
|
||||
.await?;
|
||||
|
||||
let lock = script
|
||||
.lock
|
||||
@@ -1055,7 +1238,9 @@ async fn tarball_workspace(
|
||||
lock,
|
||||
};
|
||||
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
|
||||
write_to_archive(metadata_str, format!("{}.script.json", script.path), &mut a).await?;
|
||||
archive
|
||||
.write_to_archive(&metadata_str, &format!("{}.script.json", script.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,13 +1254,10 @@ async fn tarball_workspace(
|
||||
.await?;
|
||||
|
||||
for resource in resources {
|
||||
let resource_str = serde_json::to_string_pretty(&resource).unwrap();
|
||||
write_to_archive(
|
||||
resource_str,
|
||||
format!("{}.resource.json", resource.path),
|
||||
&mut a,
|
||||
)
|
||||
.await?;
|
||||
let resource_str = &to_string_without_metadata(&resource, false).unwrap();
|
||||
archive
|
||||
.write_to_archive(&resource_str, &format!("{}.resource.json", resource.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,13 +1271,13 @@ async fn tarball_workspace(
|
||||
.await?;
|
||||
|
||||
for resource_type in resource_types {
|
||||
let resource_str = serde_json::to_string_pretty(&resource_type).unwrap();
|
||||
write_to_archive(
|
||||
resource_str,
|
||||
format!("{}.resource-type.json", resource_type.name),
|
||||
&mut a,
|
||||
)
|
||||
.await?;
|
||||
let resource_str = &to_string_without_metadata(&resource_type, false).unwrap();
|
||||
archive
|
||||
.write_to_archive(
|
||||
&resource_str,
|
||||
&format!("{}.resource-type.json", resource_type.name),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,25 +1290,49 @@ async fn tarball_workspace(
|
||||
.await?;
|
||||
|
||||
for flow in flows {
|
||||
let flow_str = serde_json::to_string_pretty(&flow).unwrap();
|
||||
write_to_archive(flow_str, format!("{}.flow.json", flow.path), &mut a).await?;
|
||||
let flow_str = &to_string_without_metadata(&flow, false).unwrap();
|
||||
archive
|
||||
.write_to_archive(&flow_str, &format!("{}.flow.json", flow.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let variables = sqlx::query_as::<_, ExportableListableVariable>(
|
||||
"SELECT *, false as is_expired FROM variable WHERE workspace_id = $1 AND is_secret = false",
|
||||
"SELECT *, false as is_expired FROM variable WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
for var in variables {
|
||||
let flow_str = serde_json::to_string_pretty(&var).unwrap();
|
||||
write_to_archive(flow_str, format!("{}.variable.json", var.path), &mut a).await?;
|
||||
let var_str = &to_string_without_metadata(&var, false).unwrap();
|
||||
archive
|
||||
.write_to_archive(&var_str, &format!("{}.variable.json", var.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
a.into_inner().await?;
|
||||
|
||||
{
|
||||
let apps = sqlx::query_as!(
|
||||
AppWithLastVersion,
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy,
|
||||
app.extra_perms, app_version.value,
|
||||
app_version.created_at, app_version.created_by from app, app_version
|
||||
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
for app in apps {
|
||||
let app_str = &to_string_without_metadata(&app, false).unwrap();
|
||||
archive
|
||||
.write_to_archive(&app_str, &format!("{}.app.json", app.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
archive.finish().await?;
|
||||
|
||||
let file = tokio::fs::File::open(file_path).await?;
|
||||
|
||||
@@ -1143,20 +1349,3 @@ async fn tarball_workspace(
|
||||
|
||||
Ok((headers, body))
|
||||
}
|
||||
|
||||
async fn write_to_archive(
|
||||
content: String,
|
||||
path: String,
|
||||
a: &mut tokio_tar::Builder<File>,
|
||||
) -> Result<()> {
|
||||
let bytes = content.as_bytes();
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mtime(0);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mode(0o777);
|
||||
header.set_cksum();
|
||||
a.append_data(&mut header, path, bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -41,3 +41,4 @@ hyper = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
lazy_static.workspace = true
|
||||
@@ -125,7 +125,7 @@ pub enum FlowStatusModule {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum JobResult {
|
||||
SingleJob(Uuid),
|
||||
ListJob(Vec<Uuid>),
|
||||
|
||||
@@ -59,6 +59,7 @@ pub struct NewFlow {
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct FlowValue {
|
||||
pub modules: Vec<FlowModule>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub failure_module: Option<FlowModule>,
|
||||
#[serde(default)]
|
||||
@@ -152,7 +153,9 @@ pub struct FlowModule {
|
||||
#[serde(alias = "input_transform")]
|
||||
pub input_transforms: HashMap<String, InputTransform>,
|
||||
pub value: FlowModuleValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_after_if: Option<StopAfterIf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suspend: Option<Suspend>,
|
||||
@@ -245,7 +248,9 @@ pub enum FlowModuleValue {
|
||||
#[serde(alias = "input_transform")]
|
||||
input_transforms: HashMap<String, InputTransform>,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lock: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
language: ScriptLang,
|
||||
},
|
||||
|
||||
@@ -26,12 +26,13 @@ pub mod variables;
|
||||
#[cfg(feature = "tracing_init")]
|
||||
pub mod tracing_init;
|
||||
|
||||
pub const DEFAULT_NUM_WORKERS: usize = 3;
|
||||
pub const DEFAULT_TIMEOUT: i32 = 300;
|
||||
pub const DEFAULT_SLEEP_QUEUE: u64 = 50;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 3;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref BASE_URL: String = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub async fn shutdown_signal(tx: tokio::sync::broadcast::Sender<()>) -> anyhow::Result<()> {
|
||||
use std::io;
|
||||
@@ -123,10 +124,8 @@ pub async fn get_latest_hash_for_path<'c>(
|
||||
script_path: &str,
|
||||
) -> error::Result<scripts::ScriptHash> {
|
||||
let script_hash_o = sqlx::query_scalar!(
|
||||
"select hash from script where path = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter') AND
|
||||
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND (workspace_id = $2 OR \
|
||||
workspace_id = 'starter')) AND
|
||||
"select hash from script where path = $1 AND workspace_id = $2 AND
|
||||
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND workspace_id = $2) AND
|
||||
deleted = false",
|
||||
script_path,
|
||||
w_id
|
||||
|
||||
@@ -210,7 +210,7 @@ pub fn to_hex_string(i: &i64) -> String {
|
||||
pub async fn get_hub_script_by_path(
|
||||
email: &str,
|
||||
path: StripPath,
|
||||
http_client: reqwest::Client,
|
||||
http_client: &reqwest::Client,
|
||||
) -> crate::error::Result<String> {
|
||||
use crate::{
|
||||
error::{to_anyhow, Error},
|
||||
@@ -239,7 +239,7 @@ pub async fn get_hub_script_by_path(
|
||||
pub async fn get_full_hub_script_by_path(
|
||||
email: &str,
|
||||
path: StripPath,
|
||||
http_client: reqwest::Client,
|
||||
http_client: &reqwest::Client,
|
||||
) -> crate::error::Result<HubScript> {
|
||||
use crate::{
|
||||
error::{to_anyhow, Error},
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn not_found_if_none<T, U: AsRef<str>>(opt: Option<T>, kind: &str, name: U)
|
||||
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub async fn list_elems_from_hub(
|
||||
http_client: reqwest::Client,
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
email: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
@@ -88,7 +88,7 @@ pub async fn list_elems_from_hub(
|
||||
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub async fn http_get_from_hub(
|
||||
http_client: reqwest::Client,
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
email: &str,
|
||||
plain: bool,
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::BASE_URL;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
|
||||
pub struct ContextualVariable {
|
||||
@@ -66,7 +68,6 @@ pub fn get_reserved_variables(
|
||||
username: &str,
|
||||
job_id: &str,
|
||||
permissioned_as: &str,
|
||||
base_url: &str,
|
||||
path: Option<String>,
|
||||
flow_id: Option<String>,
|
||||
flow_path: Option<String>,
|
||||
@@ -114,7 +115,7 @@ pub fn get_reserved_variables(
|
||||
},
|
||||
ContextualVariable {
|
||||
name: "WM_BASE_URL".to_string(),
|
||||
value: base_url.to_string(),
|
||||
value: BASE_URL.clone(),
|
||||
description: "base url of this instance".to_string(),
|
||||
},
|
||||
ContextualVariable {
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Pool, Postgres, Transaction};
|
||||
use tracing::{instrument, Instrument};
|
||||
@@ -16,7 +17,7 @@ use ulid::Ulid;
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{self, to_anyhow, Error},
|
||||
error::{self, Error},
|
||||
flow_status::{FlowStatus, JobResult, MAX_RETRY_ATTEMPTS, MAX_RETRY_INTERVAL},
|
||||
flows::{FlowModule, FlowModuleValue, FlowValue},
|
||||
scripts::{get_full_hub_script_by_path, HubScript, ScriptHash, ScriptLang},
|
||||
@@ -24,6 +25,10 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
|
||||
.user_agent("windmill/beta")
|
||||
.build().unwrap();
|
||||
|
||||
// TODO: these aren't synced, they should be moved into the queue abstraction once/if that happens.
|
||||
static ref QUEUE_PUSH_COUNT: prometheus::IntCounter = prometheus::register_int_counter!(
|
||||
"queue_push_count",
|
||||
@@ -45,7 +50,7 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
const MAX_FREE_EXECS: i32 = 1000;
|
||||
const MAX_FREE_CONCURRENT_RUNS: i32 = 3;
|
||||
const MAX_FREE_CONCURRENT_RUNS: i32 = 15;
|
||||
|
||||
pub async fn cancel_job<'c>(
|
||||
username: &str,
|
||||
@@ -82,7 +87,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
|
||||
@@ -90,7 +120,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())
|
||||
@@ -99,17 +129,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?;
|
||||
|
||||
@@ -122,55 +152,24 @@ pub async fn pull(db: &Pool<Postgres>) -> windmill_common::error::Result<Option<
|
||||
|
||||
pub async fn get_result_by_id(
|
||||
db: Pool<Postgres>,
|
||||
mut skip_direct: bool,
|
||||
w_id: String,
|
||||
flow_id: String,
|
||||
flow_id: Uuid,
|
||||
node_id: String,
|
||||
) -> error::Result<serde_json::Value> {
|
||||
let mut result_id: Option<JobResult> = None;
|
||||
let mut parent_id = Uuid::from_str(&flow_id).ok();
|
||||
while result_id.is_none() && parent_id.is_some() {
|
||||
if !skip_direct {
|
||||
let r = sqlx::query!(
|
||||
"SELECT flow_status, parent_job FROM completed_job WHERE id = $1 AND workspace_id = $2 UNION ALL SELECT flow_status, parent_job FROM queue WHERE id = $1 AND workspace_id = $2 ",
|
||||
parent_id.unwrap(),
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if let Some(r) = r {
|
||||
let value = r
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InternalErr(format!("requiring a flow status value")))?
|
||||
.to_owned();
|
||||
parent_id = r.parent_job;
|
||||
let status_o = serde_json::from_value::<FlowStatus>(value).ok();
|
||||
result_id = status_o.and_then(|status| {
|
||||
status
|
||||
.modules
|
||||
.iter()
|
||||
.find(|m| m.id() == node_id)
|
||||
.and_then(|m| m.job_result())
|
||||
});
|
||||
} else {
|
||||
parent_id = None;
|
||||
}
|
||||
} else {
|
||||
let q_parent = sqlx::query_scalar!(
|
||||
"SELECT parent_job FROM completed_job WHERE id = $1 AND workspace_id = $2 UNION ALL SELECT parent_job FROM queue WHERE id = $1 AND workspace_id = $2",
|
||||
parent_id.unwrap(),
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
parent_id = q_parent;
|
||||
skip_direct = false
|
||||
}
|
||||
}
|
||||
let job_result: Option<JobResult> = sqlx::query_scalar!(
|
||||
"SELECT leaf_jobs->$1::text FROM queue WHERE COALESCE((SELECT root_job FROM queue WHERE id = $2), $2) = id AND workspace_id = $3",
|
||||
node_id,
|
||||
flow_id,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten()
|
||||
.map(|x| serde_json::from_value(x).ok())
|
||||
.flatten();
|
||||
|
||||
let result_id = windmill_common::utils::not_found_if_none(
|
||||
result_id,
|
||||
job_result,
|
||||
"Flow result by id",
|
||||
format!("{}, {}", flow_id, node_id),
|
||||
)?;
|
||||
@@ -252,6 +251,7 @@ pub async fn push<'c>(
|
||||
scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
|
||||
schedule_path: Option<String>,
|
||||
parent_job: Option<Uuid>,
|
||||
root_job: Option<Uuid>,
|
||||
is_flow_step: bool,
|
||||
mut same_worker: bool,
|
||||
pre_run_error: Option<&windmill_common::error::Error>,
|
||||
@@ -321,7 +321,10 @@ pub async fn push<'c>(
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_super_admin {
|
||||
if usage > MAX_FREE_EXECS {
|
||||
if usage > MAX_FREE_EXECS
|
||||
&& !matches!(job_payload, JobPayload::Dependencies { .. })
|
||||
&& !matches!(job_payload, JobPayload::FlowDependencies { .. })
|
||||
{
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"User {email} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
|
||||
)));
|
||||
@@ -359,8 +362,7 @@ pub async fn push<'c>(
|
||||
match job_payload {
|
||||
JobPayload::ScriptHash { hash, path } => {
|
||||
let language = sqlx::query_scalar!(
|
||||
"SELECT language as \"language: ScriptLang\" FROM script WHERE hash = $1 AND \
|
||||
(workspace_id = $2 OR workspace_id = 'starter')",
|
||||
"SELECT language as \"language: ScriptLang\" FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
hash.0,
|
||||
workspace_id
|
||||
)
|
||||
@@ -381,7 +383,7 @@ pub async fn push<'c>(
|
||||
)
|
||||
}
|
||||
JobPayload::ScriptHub { path } => {
|
||||
let script = get_hub_script(path.clone(), email)
|
||||
let script = get_hub_script(&HTTP_CLIENT, path.clone(), email)
|
||||
.await
|
||||
.context("error fetching hub script")?;
|
||||
(
|
||||
@@ -411,11 +413,10 @@ pub async fn push<'c>(
|
||||
),
|
||||
JobPayload::FlowDependencies { path } => {
|
||||
let value_json = sqlx::query_scalar!(
|
||||
"SELECT value FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter')",
|
||||
path,
|
||||
workspace_id
|
||||
)
|
||||
"SELECT value FROM flow WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", path)))?;
|
||||
@@ -438,11 +439,10 @@ pub async fn push<'c>(
|
||||
}
|
||||
JobPayload::Flow(flow) => {
|
||||
let value_json = sqlx::query_scalar!(
|
||||
"SELECT value FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter')",
|
||||
flow,
|
||||
workspace_id
|
||||
)
|
||||
"SELECT value FROM flow WHERE path = $1 AND workspace_id = $2",
|
||||
flow,
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", flow)))?;
|
||||
@@ -504,12 +504,13 @@ pub async fn push<'c>(
|
||||
.unwrap_or_else(|| (None, None));
|
||||
|
||||
let flow_status = raw_flow.as_ref().map(FlowStatus::new);
|
||||
|
||||
let uuid = sqlx::query_scalar!(
|
||||
"INSERT INTO queue
|
||||
(workspace_id, id, running, parent_job, created_by, permissioned_as, scheduled_for,
|
||||
script_hash, script_path, raw_code, raw_lock, args, job_kind, schedule_path, raw_flow, \
|
||||
flow_status, is_flow_step, language, started_at, same_worker, pre_run_error, email, visible_to_owner)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20, $21, $22) \
|
||||
flow_status, is_flow_step, language, started_at, same_worker, pre_run_error, email, visible_to_owner, root_job)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20, $21, $22, $23) \
|
||||
RETURNING id",
|
||||
workspace_id,
|
||||
job_id,
|
||||
@@ -532,7 +533,8 @@ pub async fn push<'c>(
|
||||
same_worker,
|
||||
pre_run_error.map(|e| e.to_string()),
|
||||
email,
|
||||
visible_to_owner
|
||||
visible_to_owner,
|
||||
root_job
|
||||
)
|
||||
.fetch_one(&mut tx)
|
||||
.await
|
||||
@@ -585,17 +587,14 @@ pub fn canceled_job_to_result(job: &QueuedJob) -> serde_json::Value {
|
||||
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> {
|
||||
get_full_hub_script_by_path(
|
||||
email,
|
||||
StripPath(path),
|
||||
reqwest::ClientBuilder::new()
|
||||
.user_agent("windmill/beta")
|
||||
.build()
|
||||
.map_err(to_anyhow)?,
|
||||
)
|
||||
.await
|
||||
.map(|e| e)
|
||||
pub async fn get_hub_script(
|
||||
client: &reqwest::Client,
|
||||
path: String,
|
||||
email: &str,
|
||||
) -> error::Result<HubScript> {
|
||||
get_full_hub_script_by_path(email, StripPath(path), client)
|
||||
.await
|
||||
.map(|e| e)
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Serialize, Clone)]
|
||||
@@ -646,6 +645,12 @@ pub struct QueuedJob {
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root_job: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf_jobs: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl QueuedJob {
|
||||
|
||||
@@ -85,6 +85,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
Some(next),
|
||||
Some(schedule.path.clone()),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
|
||||
@@ -3,11 +3,10 @@ name = "windmill-worker"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
default-run = "worker"
|
||||
|
||||
[[bin]]
|
||||
name = "worker"
|
||||
path = "./src/main.rs"
|
||||
[lib]
|
||||
name = "windmill_worker"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -77,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!(
|
||||
@@ -109,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('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)
|
||||
$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,
|
||||
@@ -140,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
|
||||
|
||||
@@ -34,12 +34,13 @@ pub async fn eval_timeout(
|
||||
env: Vec<(String, serde_json::Value)>,
|
||||
creds: Option<EvalCreds>,
|
||||
by_id: Option<IdContext>,
|
||||
base_internal_url: String,
|
||||
base_internal_url: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let expr2 = expr.clone();
|
||||
let (sender, mut receiver) = oneshot::channel::<IsolateHandle>();
|
||||
let base_internal_url: String = base_internal_url.to_string();
|
||||
timeout(
|
||||
std::time::Duration::from_millis(2000),
|
||||
std::time::Duration::from_millis(3000),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut ops = vec![];
|
||||
|
||||
@@ -103,7 +104,7 @@ pub async fn eval_timeout(
|
||||
isolate.terminate_execution();
|
||||
};
|
||||
Error::ExecutionErr(format!(
|
||||
"The expression of evaluation `{expr2}` took too long to execute (>2000ms)"
|
||||
"The expression of evaluation `{expr2}` took too long to execute (>3000ms)"
|
||||
))
|
||||
})??
|
||||
}
|
||||
@@ -144,7 +145,7 @@ fn add_closing_bracket(s: &str) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
const SPLIT_PAT: &str = ";\n";
|
||||
const SPLIT_PAT: &str = ";";
|
||||
async fn eval(
|
||||
context: &mut JsRuntime,
|
||||
expr: &str,
|
||||
@@ -153,14 +154,21 @@ async fn eval(
|
||||
by_id: Option<IdContext>,
|
||||
base_internal_url: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let expr = expr.trim();
|
||||
let expr = format!(
|
||||
"{}\nreturn {};",
|
||||
expr.split(SPLIT_PAT)
|
||||
.take(expr.split(SPLIT_PAT).count() - 1)
|
||||
.join("\n"),
|
||||
expr.split(SPLIT_PAT).last().unwrap_or_else(|| "")
|
||||
);
|
||||
let exprs = expr
|
||||
.trim()
|
||||
.split(SPLIT_PAT)
|
||||
.map(|x| x.trim())
|
||||
.filter(|x| !x.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
let expr = if exprs.is_empty() {
|
||||
"return undefined;".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{};\n return {};",
|
||||
exprs.iter().take(exprs.len() - 1).join(";\n"),
|
||||
exprs.last().unwrap()
|
||||
)
|
||||
};
|
||||
let (api_code, by_id_code) = if let Some(EvalCreds { workspace, token }) = creds {
|
||||
let by_id_code = if let Some(by_id) = by_id {
|
||||
format!(
|
||||
@@ -197,12 +205,12 @@ const results = new Proxy({{}}, {{
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let v_str = match v {
|
||||
JobResult::SingleJob(x) => x.to_string(),
|
||||
JobResult::SingleJob(x) => format!("\"{x}\""),
|
||||
JobResult::ListJob(x) => {
|
||||
format!("[{}]", x.iter().map(|x| x.to_string()).join(","))
|
||||
format!("[{}]", x.iter().map(|x| format!("\"{x}\"")).join(","))
|
||||
}
|
||||
};
|
||||
format!("\"{k}\": \"{v_str}\"")
|
||||
format!("\"{k}\": {v_str}")
|
||||
})
|
||||
.join(","),
|
||||
by_id.previous_id,
|
||||
@@ -291,9 +299,8 @@ async fn op_get_result(args: Vec<String>) -> Result<serde_json::Value, anyhow::E
|
||||
let base_url = &args[3];
|
||||
let client = windmill_api_client::create_client(base_url, token.clone());
|
||||
let result = client
|
||||
.get_completed_job(workspace, &id.parse()?)
|
||||
.get_completed_job_result(workspace, &id.parse()?)
|
||||
.await?
|
||||
.result
|
||||
.clone();
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
@@ -308,7 +315,7 @@ async fn op_get_id(args: Vec<String>) -> Result<Option<serde_json::Value>, anyho
|
||||
|
||||
let client = windmill_api_client::create_client(base_url, token.clone());
|
||||
let result = client
|
||||
.result_by_id(workspace, flow_job_id, node_id, Some(true))
|
||||
.result_by_id(workspace, flow_job_id, node_id)
|
||||
.await
|
||||
.map_or(None, |e| Some(e.into_inner()));
|
||||
|
||||
@@ -346,7 +353,7 @@ mod tests {
|
||||
let code = "value.test + params.test";
|
||||
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||
let res = eval(&mut runtime, code, env, None, None, "").await?;
|
||||
let res = eval(&mut runtime, code, env, None, None, String::new().as_str()).await?;
|
||||
assert_eq!(res, json!(4));
|
||||
Ok(())
|
||||
}
|
||||
@@ -359,7 +366,7 @@ mod tests {
|
||||
multiline template`";
|
||||
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||
let res = eval(&mut runtime, code, env, None, None, "").await?;
|
||||
let res = eval(&mut runtime, code, env, None, None, String::new().as_str()).await?;
|
||||
assert_eq!(res, json!("my 5\nmultiline template"));
|
||||
Ok(())
|
||||
}
|
||||
@@ -372,7 +379,7 @@ multiline template`";
|
||||
];
|
||||
let code = r#"params.test"#;
|
||||
|
||||
let res = eval_timeout(code.to_string(), env, None, None, "".to_string()).await?;
|
||||
let res = eval_timeout(code.to_string(), env, None, None, String::new().as_str()).await?;
|
||||
assert_eq!(res, json!(2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
|
||||
use windmill_common::{
|
||||
error::{self, Error},
|
||||
utils::rd_string,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// dotenv().ok();
|
||||
|
||||
windmill_common::tracing_init::initialize_tracing();
|
||||
|
||||
let db = async {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| Error::BadConfig("DATABASE_URL env var is missing".to_string()))?;
|
||||
|
||||
let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
|
||||
Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
|
||||
Err(_) => 10,
|
||||
};
|
||||
|
||||
Ok::<Pool<Postgres>, error::Error>(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.max_lifetime(Duration::from_secs(30 * 60)) // 30 mins
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))?,
|
||||
)
|
||||
}
|
||||
.await?;
|
||||
|
||||
let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.parse::<bool>()
|
||||
.map(|b| b.then(|| SocketAddr::from(([0, 0, 0, 0], 8001))))
|
||||
.or_else(|_| s.parse::<SocketAddr>().map(Some))
|
||||
})
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||
let shutdown_signal = windmill_common::shutdown_signal(tx);
|
||||
|
||||
let base_internal_url =
|
||||
std::env::var("BASE_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
|
||||
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
|
||||
|
||||
let timeout = std::env::var("TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i32>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_TIMEOUT);
|
||||
|
||||
let workers_f = async {
|
||||
let sleep_queue = std::env::var("SLEEP_QUEUE")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u64>().ok())
|
||||
.unwrap_or(windmill_common::DEFAULT_SLEEP_QUEUE);
|
||||
let disable_nuser = std::env::var("DISABLE_NUSER")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
let disable_nsjail = std::env::var("DISABLE_NSJAIL")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(true);
|
||||
let keep_job_dir = std::env::var("KEEP_JOB_DIR")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
let sync_bucket = std::env::var("S3_CACHE_BUCKET")
|
||||
.ok()
|
||||
.map(|e| Some(e))
|
||||
.unwrap_or(None);
|
||||
|
||||
tracing::info!(
|
||||
"DISABLE_NSJAIL: {disable_nsjail}, DISABLE_NUSER: {disable_nuser}, BASE_URL: \
|
||||
{base_url}, SLEEP_QUEUE: {sleep_queue}, TIMEOUT: \
|
||||
{timeout}, KEEP_JOB_DIR: {keep_job_dir}"
|
||||
);
|
||||
let instance_name = rd_string(5);
|
||||
|
||||
let ip = windmill_common::external_ip::get_ip()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = e.to_string(), "failed to get external IP");
|
||||
"unretrievable IP".to_string()
|
||||
});
|
||||
let worker_name = format!("dt-worker-{}-{}", &instance_name, rd_string(5));
|
||||
windmill_worker::run_worker(
|
||||
&db.clone(),
|
||||
timeout,
|
||||
&instance_name,
|
||||
worker_name,
|
||||
1,
|
||||
1,
|
||||
&ip,
|
||||
sleep_queue,
|
||||
windmill_worker::WorkerConfig {
|
||||
disable_nsjail,
|
||||
disable_nuser,
|
||||
base_internal_url,
|
||||
base_url,
|
||||
keep_job_dir,
|
||||
},
|
||||
sync_bucket,
|
||||
rx.resubscribe(),
|
||||
)
|
||||
.await;
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
let metrics_f = async {
|
||||
match metrics_addr {
|
||||
Some(addr) => windmill_common::serve_metrics(addr, rx.resubscribe())
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
futures::try_join!(shutdown_signal, workers_f, metrics_f)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
92
backend/windmill-worker/src/main2.rs
Normal file
92
backend/windmill-worker/src/main2.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
// use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
// use anyhow::Context;
|
||||
// use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
|
||||
// use windmill_common::{
|
||||
// error::{self, Error},
|
||||
// utils::rd_string,
|
||||
// };
|
||||
|
||||
// #[tokio::main]
|
||||
// async fn main() -> anyhow::Result<()> {
|
||||
// // dotenv().ok();
|
||||
|
||||
// windmill_common::tracing_init::initialize_tracing();
|
||||
|
||||
// let db = async {
|
||||
// let database_url = std::env::var("DATABASE_URL")
|
||||
// .map_err(|_| Error::BadConfig("DATABASE_URL env var is missing".to_string()))?;
|
||||
|
||||
// let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
|
||||
// Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
|
||||
// Err(_) => 10,
|
||||
// };
|
||||
|
||||
// Ok::<Pool<Postgres>, error::Error>(
|
||||
// PgPoolOptions::new()
|
||||
// .max_connections(max_connections)
|
||||
// .max_lifetime(Duration::from_secs(30 * 60)) // 30 mins
|
||||
// .connect(&database_url)
|
||||
// .await
|
||||
// .map_err(|err| Error::ConnectingToDatabase(err.to_string()))?,
|
||||
// )
|
||||
// }
|
||||
// .await?;
|
||||
|
||||
// let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
|
||||
// .ok()
|
||||
// .map(|s| {
|
||||
// s.parse::<bool>()
|
||||
// .map(|b| b.then(|| SocketAddr::from(([0, 0, 0, 0], 8001))))
|
||||
// .or_else(|_| s.parse::<SocketAddr>().map(Some))
|
||||
// })
|
||||
// .transpose()?
|
||||
// .flatten();
|
||||
|
||||
// let (tx, rx) = tokio::sync::broadcast::channel::<()>(3);
|
||||
// let shutdown_signal = windmill_common::shutdown_signal(tx);
|
||||
|
||||
// let workers_f = async {
|
||||
// let instance_name = rd_string(5);
|
||||
|
||||
// let ip = windmill_common::external_ip::get_ip()
|
||||
// .await
|
||||
// .unwrap_or_else(|e| {
|
||||
// tracing::warn!(error = e.to_string(), "failed to get external IP");
|
||||
// "unretrievable IP".to_string()
|
||||
// });
|
||||
// let worker_name = format!("dt-worker-{}-{}", &instance_name, rd_string(5));
|
||||
// windmill_worker::run_worker(
|
||||
// &db.clone(),
|
||||
// &instance_name,
|
||||
// worker_name,
|
||||
// 1,
|
||||
// 1,
|
||||
// &ip,
|
||||
// rx.resubscribe(),
|
||||
// )
|
||||
// .await;
|
||||
// Ok(()) as anyhow::Result<()>
|
||||
// };
|
||||
|
||||
// let metrics_f = async {
|
||||
// match metrics_addr {
|
||||
// Some(addr) => windmill_common::serve_metrics(addr, rx.resubscribe())
|
||||
// .await
|
||||
// .map_err(anyhow::Error::from),
|
||||
// None => Ok(()),
|
||||
// }
|
||||
// };
|
||||
|
||||
// futures::try_join!(shutdown_signal, workers_f, metrics_f)?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::jobs::{add_completed_job, add_completed_job_error, schedule_again_if_scheduled};
|
||||
use crate::js_eval::{eval_timeout, EvalCreds, IdContext};
|
||||
use crate::worker;
|
||||
use crate::{worker, KEEP_JOB_DIR};
|
||||
use anyhow::Context;
|
||||
use async_recursion::async_recursion;
|
||||
use dyn_iter::DynIter;
|
||||
@@ -50,9 +50,8 @@ pub async fn update_flow_status_after_job_completion(
|
||||
unrecoverable: bool,
|
||||
same_worker_tx: Sender<Uuid>,
|
||||
worker_dir: &str,
|
||||
keep_job_dir: bool,
|
||||
base_internal_url: &str,
|
||||
stop_early_override: Option<bool>,
|
||||
base_internal_url: &str,
|
||||
) -> error::Result<()> {
|
||||
tracing::debug!("UPDATE FLOW STATUS: {flow:?} {success} {result:?} {w_id}");
|
||||
|
||||
@@ -121,7 +120,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
|
||||
let stop_early = success
|
||||
&& if let Some(expr) = r.stop_early_expr.clone() {
|
||||
compute_bool_from_expr(expr, &r.args, result.clone(), base_internal_url, None, None)
|
||||
compute_bool_from_expr(expr, &r.args, result.clone(), None, None, base_internal_url)
|
||||
.await?
|
||||
} else {
|
||||
false
|
||||
@@ -327,6 +326,21 @@ pub async fn update_flow_status_after_job_completion(
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
if let Some(job_result) = new_status.job_result() {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE queue
|
||||
SET leaf_jobs = JSONB_SET(coalesce(leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2)
|
||||
WHERE COALESCE((SELECT root_job FROM queue WHERE id = $3), $3) = id
|
||||
",
|
||||
new_status.id(),
|
||||
json!(job_result),
|
||||
flow
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +495,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
};
|
||||
|
||||
if done {
|
||||
if flow_job.same_worker && !keep_job_dir {
|
||||
if flow_job.same_worker && !*KEEP_JOB_DIR {
|
||||
let _ = tokio::fs::remove_dir_all(format!("{worker_dir}/{}", flow_job.id)).await;
|
||||
}
|
||||
|
||||
@@ -498,13 +512,12 @@ pub async fn update_flow_status_after_job_completion(
|
||||
false,
|
||||
same_worker_tx.clone(),
|
||||
worker_dir,
|
||||
keep_job_dir,
|
||||
base_internal_url,
|
||||
if stop_early {
|
||||
Some(skip_if_stop_early)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
base_internal_url,
|
||||
)
|
||||
.await?);
|
||||
}
|
||||
@@ -559,7 +572,7 @@ async fn has_failure_module<'c>(
|
||||
flow: Uuid,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
) -> Result<bool, Error> {
|
||||
sqlx::query_scalar(
|
||||
sqlx::query_scalar::<_, Option<bool>>(
|
||||
"
|
||||
SELECT raw_flow->'failure_module' != 'null'::jsonb
|
||||
FROM queue
|
||||
@@ -570,6 +583,7 @@ async fn has_failure_module<'c>(
|
||||
.fetch_one(tx)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("error during retrieval of has_failure_module: {e}")))
|
||||
.map(|v| v.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn next_retry(retry: &Retry, status: &RetryStatus) -> Option<(u16, Duration)> {
|
||||
@@ -583,9 +597,9 @@ async fn compute_bool_from_expr(
|
||||
expr: String,
|
||||
flow_args: &Option<serde_json::Value>,
|
||||
result: serde_json::Value,
|
||||
base_internal_url: &str,
|
||||
by_id: Option<IdContext>,
|
||||
creds: Option<EvalCreds>,
|
||||
base_internal_url: &str,
|
||||
) -> error::Result<bool> {
|
||||
let flow_input = flow_args.clone().unwrap_or_else(|| json!({}));
|
||||
match eval_timeout(
|
||||
@@ -598,7 +612,7 @@ async fn compute_bool_from_expr(
|
||||
.into(),
|
||||
creds,
|
||||
by_id,
|
||||
base_internal_url.to_string(),
|
||||
base_internal_url,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -722,7 +736,7 @@ async fn transform_input(
|
||||
context,
|
||||
Some(EvalCreds { workspace: workspace.to_string(), token: token.to_string() }),
|
||||
Some(by_id.clone()),
|
||||
base_internal_url.to_string(),
|
||||
base_internal_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -769,8 +783,8 @@ pub async fn handle_flow(
|
||||
client,
|
||||
last_result,
|
||||
same_worker_tx,
|
||||
base_internal_url,
|
||||
worker_dir,
|
||||
base_internal_url,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -786,8 +800,8 @@ async fn push_next_flow_job(
|
||||
client: &windmill_api_client::Client,
|
||||
mut last_result: serde_json::Value,
|
||||
same_worker_tx: Sender<Uuid>,
|
||||
base_internal_url: &str,
|
||||
worker_dir: &str,
|
||||
base_internal_url: &str,
|
||||
) -> error::Result<()> {
|
||||
let mut i = usize::try_from(status.step)
|
||||
.with_context(|| format!("invalid module index {}", status.step))?;
|
||||
@@ -816,9 +830,8 @@ async fn push_next_flow_job(
|
||||
true,
|
||||
same_worker_tx,
|
||||
worker_dir,
|
||||
false,
|
||||
base_internal_url,
|
||||
None,
|
||||
base_internal_url,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -858,7 +871,7 @@ async fn push_next_flow_job(
|
||||
.into(),
|
||||
None,
|
||||
None,
|
||||
"".to_string(),
|
||||
base_internal_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1162,8 +1175,8 @@ async fn push_next_flow_job(
|
||||
&status,
|
||||
&status_module,
|
||||
last_result.clone(),
|
||||
base_internal_url,
|
||||
previous_id,
|
||||
base_internal_url,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -1239,6 +1252,7 @@ async fn push_next_flow_job(
|
||||
Ok(v) => (Some(v), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let root_job = flow_job.root_job.or_else(|| Some(flow_job.id));
|
||||
let (uuid, inner_tx) = push(
|
||||
tx,
|
||||
&flow_job.workspace_id,
|
||||
@@ -1250,6 +1264,7 @@ async fn push_next_flow_job(
|
||||
scheduled_for_o,
|
||||
flow_job.schedule_path.clone(),
|
||||
Some(flow_job.id),
|
||||
root_job,
|
||||
true,
|
||||
continue_on_same_worker,
|
||||
err,
|
||||
@@ -1504,8 +1519,8 @@ async fn compute_next_flow_transform<'c>(
|
||||
status: &FlowStatus,
|
||||
status_module: &FlowStatusModule,
|
||||
last_result: serde_json::Value,
|
||||
base_internal_url: &str,
|
||||
previous_id: String,
|
||||
base_internal_url: &str,
|
||||
) -> error::Result<(sqlx::Transaction<'c, sqlx::Postgres>, NextFlowTransform)> {
|
||||
match &module.value {
|
||||
FlowModuleValue::Identity => Ok((
|
||||
@@ -1701,12 +1716,12 @@ async fn compute_next_flow_transform<'c>(
|
||||
b.expr.to_string(),
|
||||
&flow_job.args,
|
||||
last_result.clone(),
|
||||
base_internal_url,
|
||||
Some(idcontext.clone()),
|
||||
Some(EvalCreds {
|
||||
workspace: flow_job.workspace_id.clone(),
|
||||
token: token.to_string(),
|
||||
}),
|
||||
base_internal_url,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1920,7 +1935,7 @@ where
|
||||
vars(),
|
||||
Some(EvalCreds { workspace, token }),
|
||||
by_id,
|
||||
base_internal_url.to_string(),
|
||||
base_internal_url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -28,12 +28,25 @@ Flow Steps and Logs will be streamed during execution automatically.
|
||||
The CLI can push specifications to a windmill instance. See the
|
||||
[examples/](./examples/) folder for formats.
|
||||
|
||||
### Pushing a folder
|
||||
## Switch to a different workspace
|
||||
|
||||
You can push all files in a folder at once using `wmill push` Files MUST be
|
||||
named resource_name.\<type\>.json. They will be pushed to the remote path they
|
||||
are in, for example the file `u/admin/fib/fib.script.json` will be pushed as a
|
||||
script to u/admin/fib/fib.
|
||||
```
|
||||
wmill workspace switch <workspace_name>
|
||||
```
|
||||
|
||||
## Sync a workspace
|
||||
|
||||
### Pull
|
||||
|
||||
```
|
||||
wmill sync pull
|
||||
```
|
||||
|
||||
### Push
|
||||
|
||||
```
|
||||
wmill sync push
|
||||
```
|
||||
|
||||
### Pushing individual files
|
||||
|
||||
|
||||
105
cli/apps.ts
Normal file
105
cli/apps.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Any, model, property } from "./decoverto.ts";
|
||||
import {
|
||||
AppService,
|
||||
AppWithLastVersion,
|
||||
colors,
|
||||
microdiff,
|
||||
Policy,
|
||||
} from "./deps.ts";
|
||||
import { Difference, PushDiffs, Resource, setValueByPath } from "./types.ts";
|
||||
|
||||
@model()
|
||||
export class AppFile implements Resource, PushDiffs {
|
||||
@property(Any)
|
||||
value: any;
|
||||
@property(() => String)
|
||||
summary: string;
|
||||
@property(Any)
|
||||
policy: Policy;
|
||||
|
||||
|
||||
constructor(value: string, summary: string, policy: Policy) {
|
||||
this.value = value;
|
||||
this.summary = summary;
|
||||
this.policy = policy;
|
||||
}
|
||||
async pushDiffs(
|
||||
workspace: string,
|
||||
remotePath: string,
|
||||
diffs: Difference[],
|
||||
): Promise<void> {
|
||||
if (await AppService.existsApp({ workspace, path: remotePath })) {
|
||||
console.log(
|
||||
colors.bold.yellow(
|
||||
`Applying ${diffs.length} diffs to existing app...`,
|
||||
),
|
||||
);
|
||||
const changeset: {
|
||||
path?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
value?: any;
|
||||
policy?: Policy | undefined;
|
||||
} = {};
|
||||
for (const diff of diffs) {
|
||||
if (
|
||||
diff.type !== "REMOVE" &&
|
||||
(
|
||||
diff.path[0] !== "value" && diff.path[0] !== "policy" && (
|
||||
diff.path.length !== 1 ||
|
||||
!["path", "summary"].includes(
|
||||
diff.path[0] as string,
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid app 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
await AppService.updateApp({
|
||||
workspace,
|
||||
path: remotePath,
|
||||
requestBody: changeset,
|
||||
});
|
||||
} else {
|
||||
console.log(colors.yellow.bold("Creating new app..."));
|
||||
await AppService.createApp({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: remotePath,
|
||||
policy: this.policy,
|
||||
summary: this.summary,
|
||||
value: this.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
async push(workspace: string, remotePath: string): Promise<void> {
|
||||
let existing: AppWithLastVersion | undefined;
|
||||
try {
|
||||
existing = await AppService.getAppByPath({
|
||||
workspace: workspace,
|
||||
path: remotePath,
|
||||
});
|
||||
} catch {
|
||||
existing = undefined;
|
||||
}
|
||||
await this.pushDiffs(
|
||||
workspace,
|
||||
remotePath,
|
||||
microdiff(existing ?? {}, this, { cyclesFix: false }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// deno-lint-ignore-file no-explicit-any
|
||||
import { colors, setClient } from "./deps.ts";
|
||||
import { tryGetLoginInfo } from "./login.ts";
|
||||
import { colors, GlobalUserInfo, 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";
|
||||
|
||||
@@ -21,7 +23,7 @@ async function tryResolveWorkspace(
|
||||
{ isError: false; value: Workspace } | { isError: true; error: string }
|
||||
> {
|
||||
const cache = (opts as any).__secret_workspace;
|
||||
if (cache) return cache;
|
||||
if (cache) return { isError: false, value: cache };
|
||||
|
||||
if (opts.workspace) {
|
||||
const e = await getWorkspaceByName(opts.workspace);
|
||||
@@ -51,22 +53,42 @@ export async function resolveWorkspace(
|
||||
): Promise<Workspace> {
|
||||
const res = await tryResolveWorkspace(opts);
|
||||
if (res.isError) {
|
||||
console.log(res.error);
|
||||
return Deno.exit(-1);
|
||||
} else {
|
||||
return res.value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireLogin(opts: GlobalOptions) {
|
||||
export async function requireLogin(opts: GlobalOptions): Promise<GlobalUserInfo> {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
|
||||
let token = await tryGetLoginInfo(opts);
|
||||
|
||||
if (!token) {
|
||||
token = workspace.token;
|
||||
}
|
||||
|
||||
setClient(token, workspace.remote.substring(0, workspace.remote.length - 1));
|
||||
|
||||
try {
|
||||
return 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, false, opts);
|
||||
workspace.token = newToken;
|
||||
addWorkspace(workspace, opts);
|
||||
|
||||
setClient(
|
||||
token,
|
||||
workspace.remote.substring(0, workspace.remote.length - 1),
|
||||
);
|
||||
return await UserService.globalWhoami();
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryResolveVersion(
|
||||
|
||||
8
cli/decoverto.ts
Normal file
8
cli/decoverto.ts
Normal 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 };
|
||||
41
cli/deps.ts
41
cli/deps.ts
@@ -1,32 +1,41 @@
|
||||
// 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.69.3/mod.ts";
|
||||
export * from "https://deno.land/x/windmill@v1.69.3/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";
|
||||
export { CompletionsCommand } from "https://deno.land/x/cliffy@v0.25.7/command/completions/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 * 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";
|
||||
export { default as gitignore_parser } from "npm:gitignore-parser";
|
||||
export { default as JSZip } from "npm:jszip@3.7.1";
|
||||
|
||||
176
cli/flow.ts
176
cli/flow.ts
@@ -1,16 +1,136 @@
|
||||
// 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,
|
||||
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, decoverto, model, property } from "./decoverto.ts";
|
||||
|
||||
|
||||
// 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(Any)
|
||||
value: any;
|
||||
@property(Any)
|
||||
schema?: any;
|
||||
|
||||
constructor(value: any, summary?: string) {
|
||||
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... ${remotePath}`,
|
||||
),
|
||||
);
|
||||
|
||||
// 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
|
||||
};
|
||||
const base_changeset = { ...changeset };
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = {
|
||||
...changeset,
|
||||
...base_changeset,
|
||||
}
|
||||
await FlowService.updateFlow({
|
||||
workspace: workspace,
|
||||
path: remotePath,
|
||||
requestBody: update,
|
||||
});
|
||||
} 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;
|
||||
|
||||
@@ -22,7 +142,7 @@ async function push(opts: Options, filePath: string, remotePath: string) {
|
||||
await requireLogin(opts);
|
||||
|
||||
await pushFlow(filePath, workspace.remote, remotePath);
|
||||
console.log(colors.bold.underline.green("Flow successfully pushed"));
|
||||
console.log(colors.bold.underline.green("Flow pushed"));
|
||||
}
|
||||
|
||||
export async function pushFlow(
|
||||
@@ -30,38 +150,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 }) {
|
||||
@@ -86,23 +178,21 @@ async function list(opts: GlobalOptions & { showArchived?: boolean }) {
|
||||
}
|
||||
|
||||
new Table()
|
||||
.header(["path", "summary", "edited at", "edited by"])
|
||||
.header(["path", "summary", "edited by"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
total.map((x) => [
|
||||
x.path,
|
||||
x.summary,
|
||||
x.edited_at,
|
||||
x.edited_by,
|
||||
x.description ?? "-",
|
||||
]),
|
||||
)
|
||||
.render();
|
||||
}
|
||||
async function run(
|
||||
opts: GlobalOptions & {
|
||||
input: string[];
|
||||
data?: string;
|
||||
silent: boolean;
|
||||
},
|
||||
path: string,
|
||||
@@ -110,7 +200,8 @@ async function run(
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const input = await resolve(opts.input);
|
||||
const input = opts.data ? await resolve(opts.data) : {};
|
||||
|
||||
|
||||
const id = await JobService.runFlowByPath({
|
||||
workspace: workspace.workspaceId,
|
||||
@@ -146,6 +237,7 @@ async function run(
|
||||
|
||||
if (!opts.silent) {
|
||||
console.log(colors.green.underline.bold("Flow ran to completion"));
|
||||
console.log()
|
||||
}
|
||||
const jobInfo = await JobService.getCompletedJob({
|
||||
workspace: workspace.workspaceId,
|
||||
@@ -167,8 +259,8 @@ const command = new Command()
|
||||
.command("run", "run a flow by path.")
|
||||
.arguments("<path:string>")
|
||||
.option(
|
||||
"-i --input [inputs...:string]",
|
||||
"Inputs specified as JSON objects or simply as <name>=<value>. Supports file inputs using @<filename> and stdin using @- these also need to be formatted as JSON. Later inputs override earlier ones.",
|
||||
"-d --data <data:string>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-.",
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
|
||||
175
cli/folder.ts
175
cli/folder.ts
@@ -1,6 +1,121 @@
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
await FolderService.updateFolder({
|
||||
workspace: workspace,
|
||||
name: remotePath,
|
||||
requestBody: changeset,
|
||||
});
|
||||
} else {
|
||||
console.log(colors.bold.yellow("Creating new folder: " + remotePath));
|
||||
await FolderService.createFolder({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
name: remotePath,
|
||||
extra_perms: Object.fromEntries(this.extra_perms?.entries() ?? []),
|
||||
owners: this.owners,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
@@ -18,64 +133,18 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
|
||||
console.log(colors.bold.yellow("Pushing resource..."));
|
||||
|
||||
await pushFolder(workspace.workspaceId, filePath, remotePath);
|
||||
console.log(colors.bold.underline.green("Resource successfully pushed"));
|
||||
console.log(colors.bold.underline.green("Resource 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()
|
||||
|
||||
22
cli/hub.ts
22
cli/hub.ts
@@ -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) {
|
||||
@@ -13,7 +13,7 @@ async function pull(opts: GlobalOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requireLogin(opts);
|
||||
const userInfo = await requireLogin(opts);
|
||||
const list: {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -26,6 +26,12 @@ async function pull(opts: GlobalOptions) {
|
||||
comments: never[];
|
||||
}[] = await fetch(
|
||||
"https://hub.windmill.dev/resource_types/list",
|
||||
{
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-email": userInfo.email,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((list: { id: number; name: string }[]) =>
|
||||
@@ -56,14 +62,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
cli/login.ts
12
cli/login.ts
@@ -1,5 +1,6 @@
|
||||
import { GlobalOptions } from "./types.ts";
|
||||
import { colors, getAvailablePort, Secret, Select } from "./deps.ts";
|
||||
import { open } from 'https://deno.land/x/open/index.ts';
|
||||
|
||||
export async function loginInteractive(remote: string) {
|
||||
let token: string | undefined;
|
||||
@@ -47,13 +48,20 @@ export async function browserLogin(
|
||||
}
|
||||
|
||||
const server = Deno.listen({ transport: "tcp", port });
|
||||
console.log(`Login by going to ${baseUrl}user/cli?port=${port}`);
|
||||
const url = `${baseUrl}user/cli?port=${port}`
|
||||
console.log(`Login by going to ${url}`);
|
||||
try {
|
||||
await open(url)
|
||||
console.log("Opened browser for you");
|
||||
} catch {
|
||||
console.error(`Failed to open browser, please navigate to ${url}`)
|
||||
}
|
||||
const firstConnection = await server.accept();
|
||||
const httpFirstConnection = Deno.serveHttp(firstConnection);
|
||||
const firstRequest = (await httpFirstConnection.nextRequest())!;
|
||||
const params = new URL(firstRequest.request.url!).searchParams;
|
||||
const token = params.get("token");
|
||||
const _workspace = params.get("workspace");
|
||||
// const _workspace = params.get("workspace");
|
||||
await firstRequest?.respondWith(
|
||||
Response.redirect(baseUrl + "user/cli-success", 302),
|
||||
);
|
||||
|
||||
24
cli/main.ts
24
cli/main.ts
@@ -1,4 +1,4 @@
|
||||
import { Command, DenoLandProvider, UpgradeCommand } from "./deps.ts";
|
||||
import { Command, CompletionsCommand, DenoLandProvider, UpgradeCommand } from "./deps.ts";
|
||||
import flow from "./flow.ts";
|
||||
import script from "./script.ts";
|
||||
import workspace from "./workspace.ts";
|
||||
@@ -8,15 +8,17 @@ 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.61.1";
|
||||
const VERSION = "v1.74.2";
|
||||
|
||||
const command = new Command()
|
||||
let command: any = new Command()
|
||||
.name("wmill")
|
||||
.description("A simple CLI tool for windmill.")
|
||||
.action(() => command.showHelp())
|
||||
.globalOption(
|
||||
"--workspace <workspace:string>",
|
||||
"Specify the target workspace. This overrides the default workspace.",
|
||||
@@ -32,10 +34,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);
|
||||
@@ -59,7 +60,14 @@ const command = new Command()
|
||||
],
|
||||
provider: new DenoLandProvider({ name: "wmill" }),
|
||||
}),
|
||||
);
|
||||
)
|
||||
.command("completions", new CompletionsCommand());
|
||||
|
||||
if (Number.parseInt(VERSION.replace("v", "").replace(".", "")) > 1700) {
|
||||
command = command
|
||||
.command("push", push)
|
||||
.command("pull", pull);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.parse(Deno.args);
|
||||
|
||||
86
cli/pull.ts
86
cli/pull.ts
@@ -1,85 +1,45 @@
|
||||
// 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, JSZip } from "./deps.ts";
|
||||
import { Workspace } from "./workspace.ts";
|
||||
|
||||
export async function downloadZip(
|
||||
workspace: Workspace,
|
||||
): Promise<JSZip | undefined> {
|
||||
const requestHeaders: HeadersInit = new Headers();
|
||||
requestHeaders.set("Authorization", "Bearer " + workspace.token);
|
||||
requestHeaders.set("Content-Type", "application/octet-stream");
|
||||
|
||||
const tarResponse = await fetch(
|
||||
const zipResponse = await fetch(
|
||||
workspace.remote + "api/w/" + workspace.workspaceId +
|
||||
"/workspaces/tarball",
|
||||
"/workspaces/tarball?archive_type=zip",
|
||||
{
|
||||
headers: requestHeaders,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!tarResponse.ok) {
|
||||
if (!zipResponse.ok) {
|
||||
console.log(
|
||||
colors.red(
|
||||
"Failed to request tarball from API " + tarResponse.statusText,
|
||||
"Failed to request tarball from API " + zipResponse.statusText,
|
||||
),
|
||||
);
|
||||
console.log(await tarResponse.text());
|
||||
return;
|
||||
throw new Error(await zipResponse.text());
|
||||
}
|
||||
const blob = await zipResponse.blob();
|
||||
return await JSZip.loadAsync(blob);
|
||||
}
|
||||
|
||||
const streamReader = tarResponse.body?.getReader();
|
||||
if (!streamReader) {
|
||||
console.log(colors.red("Failed to read tar request body"));
|
||||
return;
|
||||
}
|
||||
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."));
|
||||
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 +47,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;
|
||||
|
||||
261
cli/push.ts
261
cli/push.ts
@@ -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;
|
||||
|
||||
@@ -1,65 +1,128 @@
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ResourceService.updateResourceType({
|
||||
workspace: workspace,
|
||||
path: remotePath,
|
||||
requestBody: changeset,
|
||||
});
|
||||
} else {
|
||||
console.log(colors.yellow.bold("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;
|
||||
@@ -74,7 +137,7 @@ async function push(opts: PushOptions, filePath: string, name: string) {
|
||||
console.log(colors.bold.yellow("Pushing resource..."));
|
||||
|
||||
await pushResourceType(workspace.workspaceId, filePath, name);
|
||||
console.log(colors.bold.underline.green("Resource successfully pushed"));
|
||||
console.log(colors.bold.underline.green("Resource pushed"));
|
||||
}
|
||||
|
||||
async function list(opts: GlobalOptions) {
|
||||
|
||||
195
cli/resource.ts
195
cli/resource.ts
@@ -1,80 +1,141 @@
|
||||
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) {
|
||||
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.bold("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;
|
||||
@@ -94,7 +155,7 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) {
|
||||
console.log(colors.bold.yellow("Pushing resource..."));
|
||||
|
||||
await pushResource(workspace.workspaceId, filePath, remotePath);
|
||||
console.log(colors.bold.underline.green("Resource successfully pushed"));
|
||||
console.log(colors.bold.underline.green(`Resource ${remotePath} pushed`));
|
||||
}
|
||||
|
||||
async function list(opts: GlobalOptions) {
|
||||
|
||||
220
cli/script.ts
220
cli/script.ts
@@ -2,20 +2,53 @@
|
||||
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";
|
||||
import { writeAllSync } from "https://deno.land/std@0.176.0/streams/mod.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(
|
||||
@@ -44,7 +77,78 @@ async function push(
|
||||
|
||||
await requireLogin(opts);
|
||||
await pushScript(filePath, contentPath, workspace.workspaceId, remotePath);
|
||||
console.log(colors.bold.underline.green("Script successfully pushed"));
|
||||
console.log(colors.bold.underline.green(`Script ${remotePath} pushed`));
|
||||
}
|
||||
|
||||
export async function handleScriptMetadata(path: string, workspace: string, alreadySynced: string[]): Promise<boolean> {
|
||||
if (path.endsWith(".script.json")) {
|
||||
const contentPath = await findContentFile(path)
|
||||
return handleFile(contentPath, await Deno.readTextFile(contentPath), workspace, alreadySynced)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFile(path: string, content: string, workspace: string, alreadySynced: string[]): Promise<boolean> {
|
||||
if (path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".go") || path.endsWith(".sh")) {
|
||||
if (alreadySynced.includes(path)) {
|
||||
return true
|
||||
}
|
||||
alreadySynced.push(path)
|
||||
const remotePath = path.substring(0, path.length - 3);
|
||||
const metaPath = remotePath + ".script.json";
|
||||
let typed = undefined
|
||||
try {
|
||||
await Deno.stat(metaPath)
|
||||
typed = JSON.parse(await Deno.readTextFile(metaPath))
|
||||
typed = decoverto.type(ScriptFile).plainToInstance(typed);
|
||||
} catch { }
|
||||
const language = inferContentTypeFromFilePath(path);
|
||||
|
||||
try {
|
||||
const remote = await ScriptService.getScriptByPath({
|
||||
workspace,
|
||||
path: remotePath,
|
||||
});
|
||||
await ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: {
|
||||
content,
|
||||
description: typed.description,
|
||||
language,
|
||||
path: remotePath,
|
||||
summary: typed.summary,
|
||||
is_template: typed.is_template,
|
||||
kind: typed.kind,
|
||||
lock: typed.lock,
|
||||
parent_hash: remote.hash,
|
||||
schema: typed.schema,
|
||||
},
|
||||
});
|
||||
console.log(colors.yellow.bold(`Creating script with a parent ${remotePath}`))
|
||||
} catch {
|
||||
// no parent hash
|
||||
await ScriptService.createScript({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
content,
|
||||
description: typed.description,
|
||||
language,
|
||||
path: remotePath,
|
||||
summary: typed.summary,
|
||||
is_template: typed.is_template,
|
||||
kind: typed.kind,
|
||||
lock: typed.lock,
|
||||
parent_hash: undefined,
|
||||
schema: typed.schema,
|
||||
},
|
||||
});
|
||||
console.log(colors.yellow.bold(`Creating script without parent ${remotePath}`))
|
||||
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function findContentFile(filePath: string) {
|
||||
@@ -52,6 +156,7 @@ export async function findContentFile(filePath: string) {
|
||||
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(
|
||||
@@ -70,7 +175,7 @@ export async function findContentFile(filePath: string) {
|
||||
if (validCandidates.length > 1) {
|
||||
throw new Error(
|
||||
"No content path given and more then one candidate found: " +
|
||||
validCandidates.join(", "),
|
||||
validCandidates.join(", "),
|
||||
);
|
||||
}
|
||||
if (validCandidates.length < 1) {
|
||||
@@ -79,23 +184,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 {
|
||||
@@ -150,66 +267,41 @@ async function list(opts: GlobalOptions & { showArchived?: boolean }) {
|
||||
}
|
||||
|
||||
new Table()
|
||||
.header(["path", "hash", "kind", "language", "created at", "created by"])
|
||||
.header(["path", "summary", "language", "created by"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
total.map((x) => [
|
||||
x.path,
|
||||
x.hash,
|
||||
x.kind,
|
||||
x.summary,
|
||||
x.language,
|
||||
x.created_at,
|
||||
x.created_by,
|
||||
]),
|
||||
)
|
||||
.render();
|
||||
}
|
||||
|
||||
export async function resolve(inputs: string[]): Promise<Record<string, any>> {
|
||||
let result = {};
|
||||
|
||||
if (!inputs) {
|
||||
return result;
|
||||
export async function resolve(input: string): Promise<Record<string, any>> {
|
||||
if (!input) {
|
||||
throw new Error("No data given");
|
||||
}
|
||||
|
||||
for (const input of inputs) {
|
||||
let data: string;
|
||||
if (input.startsWith("@")) {
|
||||
if (input == "@-") {
|
||||
data = new TextDecoder().decode(await readAll(Deno.stdin));
|
||||
} else {
|
||||
data = await Deno.readTextFile(input.substring(1));
|
||||
}
|
||||
} else {
|
||||
if (input.startsWith("{")) {
|
||||
data = input;
|
||||
} else {
|
||||
const key = input.split("=", 1)[0];
|
||||
const value = input.substring(key.length + 1);
|
||||
let o;
|
||||
try {
|
||||
o = JSON.parse(value);
|
||||
} catch {
|
||||
o = value;
|
||||
}
|
||||
data = JSON.stringify(Object.fromEntries([[key, o]]));
|
||||
}
|
||||
}
|
||||
let jsonObj;
|
||||
try {
|
||||
jsonObj = JSON.parse(data);
|
||||
} catch {
|
||||
jsonObj = data;
|
||||
}
|
||||
result = { ...result, ...jsonObj };
|
||||
if (input == "@-") {
|
||||
input = new TextDecoder().decode(await readAll(Deno.stdin));
|
||||
} if (input[0] == "@") {
|
||||
input = await Deno.readTextFile(input.substring(1));
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch (e) {
|
||||
console.error("Impossible to parse input as JSON", input)
|
||||
throw e
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function run(
|
||||
opts: GlobalOptions & {
|
||||
input: string[];
|
||||
data?: string;
|
||||
silent: boolean;
|
||||
},
|
||||
path: string,
|
||||
@@ -217,7 +309,8 @@ async function run(
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const input = await resolve(opts.input);
|
||||
|
||||
const input = opts.data ? await resolve(opts.data) : {};
|
||||
const id = await JobService.runScriptByPath({
|
||||
workspace: workspace.workspaceId,
|
||||
path,
|
||||
@@ -250,7 +343,9 @@ export async function track_job(workspace: string, id: string) {
|
||||
const result = await JobService.getCompletedJob({ workspace, id });
|
||||
|
||||
console.log(result.logs);
|
||||
console.log()
|
||||
console.log(colors.bold.underline.green("Job Completed"));
|
||||
console.log()
|
||||
return;
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -289,7 +384,7 @@ export async function track_job(workspace: string, id: string) {
|
||||
}
|
||||
|
||||
if (updates.new_logs) {
|
||||
console.log(updates.new_logs);
|
||||
writeAllSync(Deno.stdout, new TextEncoder().encode(updates.new_logs));
|
||||
logOffset += updates.new_logs.length;
|
||||
}
|
||||
|
||||
@@ -312,12 +407,15 @@ export async function track_job(workspace: string, id: string) {
|
||||
if ((final_job.logs?.length ?? -1) > logOffset) {
|
||||
console.log(final_job.logs!.substring(logOffset));
|
||||
}
|
||||
|
||||
console.log("\n")
|
||||
if (final_job.success) {
|
||||
console.log(colors.bold.underline.green("Job Completed"));
|
||||
|
||||
} else {
|
||||
console.log(colors.bold.underline.red("Job Completed"));
|
||||
}
|
||||
console.log()
|
||||
|
||||
} catch {
|
||||
console.log("Job appears to have completed, but no data can be retrieved");
|
||||
}
|
||||
@@ -352,8 +450,8 @@ const command = new Command()
|
||||
.command("run", "run a script by path")
|
||||
.arguments("<path:string>")
|
||||
.option(
|
||||
"-i --input [inputs...:string]",
|
||||
"Inputs specified as JSON objects or simply as <name>=<value>. Supports file inputs using @<filename> and stdin using @- these also need to be formatted as JSON. Later inputs override earlier ones.",
|
||||
"-d --data <data:string>",
|
||||
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-.",
|
||||
)
|
||||
.option(
|
||||
"-s --silent",
|
||||
|
||||
643
cli/sync.ts
Normal file
643
cli/sync.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { requireLogin, resolveWorkspace } from "./context.ts";
|
||||
import {
|
||||
colors,
|
||||
Command,
|
||||
Confirm,
|
||||
ensureDir,
|
||||
gitignore_parser,
|
||||
JSZip,
|
||||
microdiff,
|
||||
path,
|
||||
ScriptService,
|
||||
FolderService,
|
||||
ResourceService,
|
||||
VariableService,
|
||||
AppService,
|
||||
FlowService
|
||||
} from "./deps.ts";
|
||||
import {
|
||||
Difference,
|
||||
getTypeStrFromPath,
|
||||
GlobalOptions,
|
||||
inferTypeFromPath,
|
||||
setValueByPath,
|
||||
} from "./types.ts";
|
||||
import { downloadZip } from "./pull.ts";
|
||||
import { FolderFile } from "./folder.ts";
|
||||
import { ResourceTypeFile } from "./resource-type.ts";
|
||||
import {
|
||||
handleScriptMetadata,
|
||||
ScriptFile,
|
||||
} from "./script.ts";
|
||||
import { ResourceFile } from "./resource.ts";
|
||||
import { FlowFile } from "./flow.ts";
|
||||
import { VariableFile } from "./variable.ts";
|
||||
import { handleFile } from "./script.ts";
|
||||
import { equal } from "https://deno.land/x/equal/mod.ts";
|
||||
import { diffCharacters } from "https://deno.land/x/diff/mod.ts";
|
||||
type DynFSElement = {
|
||||
isDirectory: boolean;
|
||||
path: string;
|
||||
getContentBytes(): Promise<Uint8Array>;
|
||||
getContentText(): Promise<string>;
|
||||
getChildren(): AsyncIterable<DynFSElement>;
|
||||
};
|
||||
|
||||
async function FSFSElement(p: string): Promise<DynFSElement> {
|
||||
function _internal_element(
|
||||
localP: string,
|
||||
isDir: boolean,
|
||||
): DynFSElement {
|
||||
return {
|
||||
isDirectory: isDir,
|
||||
path: localP.substring(p.length + 1),
|
||||
async *getChildren(): AsyncIterable<DynFSElement> {
|
||||
for await (const e of Deno.readDir(localP)) {
|
||||
yield _internal_element(path.join(localP, e.name), e.isDirectory);
|
||||
}
|
||||
},
|
||||
async getContentBytes(): Promise<Uint8Array> {
|
||||
return await Deno.readFile(localP);
|
||||
},
|
||||
async getContentText(): Promise<string> {
|
||||
return await Deno.readTextFile(localP);
|
||||
},
|
||||
};
|
||||
}
|
||||
return _internal_element(p, (await Deno.stat(p)).isDirectory);
|
||||
}
|
||||
|
||||
function ZipFSElement(zip: JSZip): DynFSElement {
|
||||
function _internal_file(p: string, f: JSZip.JSZipObject): DynFSElement {
|
||||
return {
|
||||
isDirectory: false,
|
||||
path: p,
|
||||
// deno-lint-ignore require-yield
|
||||
async *getChildren(): AsyncIterable<DynFSElement> {
|
||||
throw new Error("Cannot get children of file");
|
||||
},
|
||||
async getContentBytes(): Promise<Uint8Array> {
|
||||
return await f.async("uint8array");
|
||||
},
|
||||
async getContentText(): Promise<string> {
|
||||
return await f.async("text");
|
||||
},
|
||||
};
|
||||
}
|
||||
function _internal_folder(p: string, zip: JSZip): DynFSElement {
|
||||
return {
|
||||
isDirectory: true,
|
||||
path: p,
|
||||
async *getChildren(): AsyncIterable<DynFSElement> {
|
||||
for (const filename in zip.files) {
|
||||
const file = zip.files[filename];
|
||||
const totalPath = path.join(p, filename);
|
||||
if (file.dir) {
|
||||
const e = zip.folder(file.name)!;
|
||||
yield _internal_folder(totalPath, e);
|
||||
} else {
|
||||
yield _internal_file(totalPath, file);
|
||||
}
|
||||
}
|
||||
},
|
||||
async getContentBytes(): Promise<Uint8Array> {
|
||||
throw new Error("Cannot get content of folder");
|
||||
},
|
||||
async getContentText(): Promise<string> {
|
||||
throw new Error("Cannot get content of folder");
|
||||
},
|
||||
};
|
||||
}
|
||||
return _internal_folder("./", zip);
|
||||
}
|
||||
|
||||
async function* readDirRecursiveWithIgnore(
|
||||
ignore: (path: string, isDirectory: boolean) => boolean,
|
||||
root: DynFSElement,
|
||||
): AsyncGenerator<
|
||||
{
|
||||
path: string;
|
||||
ignored: boolean;
|
||||
isDirectory: boolean;
|
||||
getContentBytes(): Promise<Uint8Array>;
|
||||
getContentText(): Promise<string>;
|
||||
}
|
||||
> {
|
||||
const stack: {
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
ignored: boolean;
|
||||
c(): AsyncIterable<DynFSElement>;
|
||||
getContentBytes(): Promise<Uint8Array>;
|
||||
getContentText(): Promise<string>;
|
||||
}[] = [{
|
||||
path: root.path,
|
||||
ignored: ignore(root.path, root.isDirectory),
|
||||
isDirectory: root.isDirectory,
|
||||
c: root.getChildren,
|
||||
getContentBytes(): Promise<Uint8Array> {
|
||||
throw undefined;
|
||||
},
|
||||
getContentText(): Promise<string> {
|
||||
throw undefined;
|
||||
},
|
||||
}];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const e = stack.pop()!;
|
||||
yield e;
|
||||
if (!e.isDirectory) continue;
|
||||
for await (const e2 of e.c()) {
|
||||
stack.push({
|
||||
path: e2.path,
|
||||
ignored: e.ignored || ignore(e2.path, e2.isDirectory),
|
||||
isDirectory: e2.isDirectory,
|
||||
getContentBytes: e2.getContentBytes,
|
||||
getContentText: e2.getContentText,
|
||||
c: e2.getChildren,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Added = { name: "added"; path: string; content: string };
|
||||
type Deleted = { name: "deleted"; path: string; };
|
||||
type Edit = { name: "edited"; path: string; before: string; after: string; };
|
||||
|
||||
type Change = Added | Deleted | Edit;
|
||||
|
||||
async function elementsToMap(els: DynFSElement, ignore: (path: string, isDirectory: boolean) => boolean): Promise<{ [key: string]: string }> {
|
||||
const map: { [key: string]: string } = {};
|
||||
for await (const entry of readDirRecursiveWithIgnore(
|
||||
ignore,
|
||||
els,
|
||||
)) {
|
||||
if (entry.isDirectory || entry.ignored) continue;
|
||||
const content = await entry.getContentText();
|
||||
map[entry.path] = content;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
async function compareDynFSElement(
|
||||
els1: DynFSElement, els2: DynFSElement,
|
||||
ignore: (path: string, isDirectory: boolean) => boolean,
|
||||
raw: boolean
|
||||
): Promise<Change[]> {
|
||||
|
||||
const [m1, m2] = raw ? [await elementsToMap(els1, ignore), {}] :
|
||||
await Promise.all([elementsToMap(els1, ignore), elementsToMap(els2, ignore)]);
|
||||
|
||||
const changes: Change[] = [];
|
||||
|
||||
for (const [k, v] of Object.entries(m1)) {
|
||||
if (m2[k] === undefined) {
|
||||
changes.push({ name: "added", path: k, content: v });
|
||||
} else if (m2[k] != v && (!k.endsWith(".json") || !equal(JSON.parse(v), JSON.parse(m2[k])))) {
|
||||
// await Deno.writeTextFile("/tmp/k", m2[k])
|
||||
// await Deno.writeTextFile("/tmp/v", v)
|
||||
// console.log(k)
|
||||
// if (k.includes("flow"))
|
||||
// Deno.exit(1)
|
||||
changes.push({ name: "edited", path: k, after: v, before: m2[k] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k] of Object.entries(m2)) {
|
||||
if (m1[k] === undefined) {
|
||||
changes.push({ name: "deleted", path: k });
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
const isNotWmillFile = (p: string, isDirectory: boolean) => {
|
||||
if (p.endsWith("/")) {
|
||||
return false
|
||||
}
|
||||
if (isDirectory) {
|
||||
return !p.startsWith("u/") && !p.startsWith("f/") && !p.startsWith("g/")
|
||||
}
|
||||
|
||||
try {
|
||||
const typ = getTypeStrFromPath(p)
|
||||
if (typ == 'resource-type') {
|
||||
return p.includes('/')
|
||||
} else {
|
||||
return !p.startsWith("u/") && !p.startsWith("f/") && !p.startsWith("g/")
|
||||
}
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const isWhitelisted = (p: string) => {
|
||||
return p == "./" || p == "" || p == "u" || p == "f" || p == "g"
|
||||
}
|
||||
async function ignoreF() {
|
||||
try {
|
||||
const ignore: {
|
||||
accepts(file: string): boolean;
|
||||
denies(file: string): boolean;
|
||||
} = gitignore_parser.compile(
|
||||
await Deno.readTextFile(".wmillignore"),
|
||||
);
|
||||
|
||||
return (p: string, isDirectory: boolean) => {
|
||||
return !isWhitelisted(p) && (isNotWmillFile(p, isDirectory) || ignore.denies(p));
|
||||
}
|
||||
} catch (e) {
|
||||
return (p: string, isDirectory: boolean) => !isWhitelisted(p) && isNotWmillFile(p, isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
async function pull(
|
||||
opts: GlobalOptions & { raw: boolean; yes: boolean, failConflicts: boolean },
|
||||
) {
|
||||
|
||||
|
||||
if (!opts.raw) {
|
||||
await ensureDir(path.join(Deno.cwd(), ".wmill"));
|
||||
}
|
||||
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
|
||||
console.log(colors.gray("Computing the files to update locally to match remote (taking .wmillignore into account)"));
|
||||
const remote = ZipFSElement((await downloadZip(workspace))!)
|
||||
const local = await FSFSElement(path.join(Deno.cwd(), opts.raw ? "" : ".wmill"))
|
||||
const changes = await compareDynFSElement(remote, local, await ignoreF(), opts.raw)
|
||||
|
||||
|
||||
console.log(`remote -> local: ${changes.length} changes to apply`);
|
||||
if (changes.length > 0) {
|
||||
|
||||
prettyChanges(changes)
|
||||
if (
|
||||
!opts.yes && !opts.raw && !(await Confirm.prompt({ message: `Do you want to apply these ${changes.length} changes?`, default: true }))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const conflicts = []
|
||||
console.log(colors.gray(`Applying changes to files ...`));
|
||||
for await (const change of changes) {
|
||||
const target = path.join(Deno.cwd(), change.path);
|
||||
const stateTarget = path.join(Deno.cwd(), ".wmill", change.path)
|
||||
if (change.name === "edited") {
|
||||
|
||||
try {
|
||||
const currentLocal = await Deno.readTextFile(target)
|
||||
if (currentLocal !== change.before) {
|
||||
console.log(colors.red(`Conflict detected on ${change.path}\nBoth local and remote have been modified.`))
|
||||
if (opts.failConflicts) {
|
||||
conflicts.push({ local: currentLocal, change, path: change.path })
|
||||
continue;
|
||||
} else if (opts.yes) {
|
||||
console.log(colors.red(`Override local version with remote since --yes was passed and no --fail-conflicts.`))
|
||||
}
|
||||
else {
|
||||
showConflict(change.path, currentLocal, change.after)
|
||||
if (await Confirm.prompt("Preserve local (push to change remote and avoid seeing this again)?")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
if (change.path.endsWith(".json")) {
|
||||
const diffs =
|
||||
microdiff(
|
||||
JSON.parse(change.before),
|
||||
JSON.parse(change.after),
|
||||
{ cyclesFix: false },
|
||||
)
|
||||
|
||||
console.log(`Editing ${getTypeStrFromPath(change.path)} json ${change.path}`)
|
||||
await applyDiff(
|
||||
diffs,
|
||||
target,
|
||||
);
|
||||
} else {
|
||||
console.log(`Editing script ${change.path}`)
|
||||
await Deno.writeTextFile(target, change.after);
|
||||
}
|
||||
if (!opts.raw) {
|
||||
await ensureDir(path.dirname(stateTarget))
|
||||
await Deno.copyFile(target, stateTarget);
|
||||
}
|
||||
} else if (change.name === "added") {
|
||||
await ensureDir(path.dirname(target))
|
||||
if (!opts.raw) {
|
||||
await ensureDir(path.dirname(stateTarget))
|
||||
console.log(`Adding ${getTypeStrFromPath(change.path)} ${change.path}`)
|
||||
}
|
||||
await Deno.writeTextFile(target, change.content);
|
||||
if (!opts.raw) {
|
||||
await Deno.copyFile(target, stateTarget);
|
||||
}
|
||||
} else if (change.name === "deleted") {
|
||||
try {
|
||||
console.log(`Deleting ${getTypeStrFromPath(change.path)} ${change.path}`)
|
||||
await Deno.remove(target)
|
||||
if (!opts.raw) {
|
||||
await Deno.remove(stateTarget);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!opts.raw) {
|
||||
await Deno.remove(stateTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.failConflicts) {
|
||||
if (conflicts.length > 0) {
|
||||
console.error(colors.red(`Conflicts were found`))
|
||||
console.log("Conflicts:")
|
||||
for (const conflict of conflicts) {
|
||||
showConflict(conflict.path, conflict.local, conflict.change.after)
|
||||
}
|
||||
console.log(colors.red(`Please resolve theses conflicts manually by either:
|
||||
- reverting the content back to its remote (\`wmill pull\` and refuse to preserve local when prompted)
|
||||
- pushing the changes with \`wmill push --skip-pull\` to override wmill with all your local changes
|
||||
`))
|
||||
Deno.exit(1)
|
||||
}
|
||||
}
|
||||
console.log(colors.green.underline(`Done! All ${changes.length} changes applied locally.`));
|
||||
}
|
||||
|
||||
|
||||
function showConflict(path: string, local: string, remote: string) {
|
||||
console.log(colors.yellow(`- ${path}`))
|
||||
|
||||
let finalString = "";
|
||||
for (const character of diffCharacters(local, remote)) {
|
||||
if (character.wasRemoved) {
|
||||
// print red if removed without newline
|
||||
finalString += `\x1b[31m${character.character}\x1b[0m`;
|
||||
} else if (character.wasAdded) {
|
||||
// print green if added
|
||||
finalString += `\x1b[32m${character.character}\x1b[0m`;
|
||||
} else {
|
||||
// print white if unchanged
|
||||
finalString += `\x1b[37m${character.character}\x1b[0m`;
|
||||
}
|
||||
}
|
||||
console.log(finalString);
|
||||
console.log("\x1b[31mlocal\x1b[31m - \x1b[32mremote\x1b[32m")
|
||||
console.log()
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await Deno.writeTextFile(file, JSON.stringify(json, undefined, " "), {
|
||||
create: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function prettyChanges(changes: Change[]) {
|
||||
for (const change of changes) {
|
||||
if (change.name === "added") {
|
||||
console.log(colors.green(`+ ${getTypeStrFromPath(change.path)} ` + change.path));
|
||||
} else if (change.name === "deleted") {
|
||||
console.log(colors.red(`- ${getTypeStrFromPath(change.path)} ` + change.path));
|
||||
} else if (change.name === "edited") {
|
||||
console.log(colors.yellow(`~ ${getTypeStrFromPath(change.path)} ` + change.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function removeSuffix(str: string, suffix: string) {
|
||||
return str.slice(0, str.length - suffix.length);
|
||||
}
|
||||
|
||||
async function push(opts: GlobalOptions & { raw: boolean, yes: boolean, skipPull: boolean, failConflicts: boolean }) {
|
||||
|
||||
|
||||
if (!opts.raw) {
|
||||
if (!opts.skipPull) {
|
||||
console.log(colors.gray("You need to be up-to-date before pushing, pulling first."))
|
||||
await pull(opts)
|
||||
console.log(colors.green("Pull done, now pushing."))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
|
||||
console.log(colors.gray("Computing the files to update on the remote to match local (taking .wmillignore into account)"));
|
||||
const remote = ZipFSElement((await downloadZip(workspace))!)
|
||||
const local = await FSFSElement(path.join(Deno.cwd(), ""))
|
||||
const changes = await compareDynFSElement(local, remote, await ignoreF(), opts.raw)
|
||||
|
||||
console.log(`remote <- local: ${changes.length} changes to apply`);
|
||||
if (changes.length > 0) {
|
||||
|
||||
prettyChanges(changes)
|
||||
if (
|
||||
!opts.yes && !(await Confirm.prompt({ message: `Do you want to apply these ${changes.length} changes?`, default: true }))
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.log(colors.gray(`Applying changes to files ...`));
|
||||
const alreadySynced: string[] = []
|
||||
for await (const change of changes) {
|
||||
const stateTarget = path.join(Deno.cwd(), ".wmill", change.path)
|
||||
if (change.name === "edited") {
|
||||
if (await handleScriptMetadata(change.path, workspace.workspaceId, alreadySynced)) {
|
||||
if (!opts.raw) {
|
||||
await Deno.writeTextFile(stateTarget, change.after);
|
||||
}
|
||||
continue
|
||||
} else if (await handleFile(change.path, change.after, workspace.workspaceId, alreadySynced)) {
|
||||
if (!opts.raw) {
|
||||
await Deno.writeTextFile(stateTarget, change.after);
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!opts.raw) {
|
||||
await ensureDir(path.dirname(stateTarget))
|
||||
console.log(`Editing ${getTypeStrFromPath(change.path)} ${change.path}`)
|
||||
}
|
||||
const obj = inferTypeFromPath(change.path, JSON.parse(change.after))
|
||||
|
||||
const diff = microdiff(inferTypeFromPath(change.path, JSON.parse(change.before)), obj, { cyclesFix: false });
|
||||
await applyDiff(
|
||||
workspace.workspaceId,
|
||||
change.path.split(".")[0],
|
||||
obj,
|
||||
diff,
|
||||
);
|
||||
if (!opts.raw) {
|
||||
await Deno.writeTextFile(stateTarget, change.after);
|
||||
}
|
||||
} else if (change.name === "added") {
|
||||
if (change.path.endsWith(".script.json")) {
|
||||
continue
|
||||
} else if (await handleFile(change.path, change.content, workspace.workspaceId, alreadySynced)) {
|
||||
continue
|
||||
}
|
||||
if (!opts.raw) {
|
||||
await ensureDir(path.dirname(stateTarget))
|
||||
console.log(`Adding ${getTypeStrFromPath(change.path)} ${change.path}`)
|
||||
}
|
||||
const obj = inferTypeFromPath(change.path, JSON.parse(change.content))
|
||||
const diff = microdiff({}, obj, { cyclesFix: false });
|
||||
await applyDiff(
|
||||
workspace.workspaceId,
|
||||
change.path.split(".")[0],
|
||||
obj,
|
||||
diff,
|
||||
);
|
||||
if (!opts.raw) {
|
||||
await Deno.writeTextFile(stateTarget, change.content);
|
||||
}
|
||||
} else if (change.name === "deleted") {
|
||||
if (!change.path.includes(".json")) {
|
||||
continue
|
||||
}
|
||||
console.log(`Deleting ${getTypeStrFromPath(change.path)} ${change.path}`)
|
||||
const typ = getTypeStrFromPath(change.path)
|
||||
const workspaceId = workspace.workspaceId;
|
||||
switch (typ) {
|
||||
case "script": {
|
||||
const script = await ScriptService.getScriptByPath({ workspace: workspaceId, path: removeSuffix(change.path, ".script.json") })
|
||||
await ScriptService.deleteScriptByHash({ workspace: workspaceId, hash: script.hash })
|
||||
break;
|
||||
}
|
||||
case "folder":
|
||||
await FolderService.deleteFolder({ workspace: workspaceId, name: change.path.split('/')[1] })
|
||||
break;
|
||||
case "resource":
|
||||
await ResourceService.deleteResource({ workspace: workspaceId, path: removeSuffix(change.path, ".resource.json") })
|
||||
break;
|
||||
case "resource-type":
|
||||
await ResourceService.deleteResourceType({ workspace: workspaceId, path: removeSuffix(change.path, ".resource-type.json") })
|
||||
break
|
||||
case "flow":
|
||||
await FlowService.deleteFlowByPath({ workspace: workspaceId, path: removeSuffix(change.path, ".flow.json") })
|
||||
break
|
||||
case "app":
|
||||
await AppService.deleteApp({ workspace: workspaceId, path: removeSuffix(change.path, ".app.json") })
|
||||
break
|
||||
case "variable":
|
||||
await VariableService.deleteVariable({ workspace: workspaceId, path: removeSuffix(change.path, ".variable.json") })
|
||||
break
|
||||
default:
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Deno.remove(stateTarget)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
console.log(colors.green.underline(`Done! All ${changes.length} changes pushed to the remote workspace.`));
|
||||
|
||||
}
|
||||
|
||||
|
||||
async 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];
|
||||
}
|
||||
}
|
||||
if (diffs.length === 0) {
|
||||
console.log("No diffs to apply to " + remotePath)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await file.pushDiffs(workspace, remotePath, diffs);
|
||||
} catch (e) {
|
||||
console.error("Failing to apply diffs to " + remotePath)
|
||||
console.error(e.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.command("pull")
|
||||
.description(
|
||||
"Pull any remote changes and apply them locally. Use --raw for usage without local state tracking.",
|
||||
)
|
||||
.option("--fail-conflicts", "Error on conflicts (both remote and local have changes on the same item)")
|
||||
.option("--yes", "Pull without needing confirmation")
|
||||
.option("--raw", "Pull without using state, just overwrite.")
|
||||
.action(pull as any)
|
||||
.command("push")
|
||||
.description(
|
||||
"Push any local changes and apply them remotely. Use --raw for usage without local state tracking.",
|
||||
)
|
||||
.option("--fail-conflicts", "Error on conflicts (both remote and local have changes on the same item)")
|
||||
.option("--skip-pull", "Push without pulling first")
|
||||
.option("--yes", "Push without needing confirmation")
|
||||
.option("--raw", "Push without using state, just overwrite.")
|
||||
.action(push as any);
|
||||
|
||||
export default command;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user