Compare commits

..

4 Commits

Author SHA1 Message Date
Ruben Fiszel
ece4573076 all 2023-02-01 19:46:48 +01:00
Kai Jellinghaus
833d0df965 Add webhook request histogram 2023-01-26 07:03:49 +01:00
Kai Jellinghaus
1e7b7cc8d6 Update SQLX 2023-01-26 06:59:29 +01:00
Kai Jellinghaus
1de8eefe96 Add workspace webhook 2023-01-26 06:42:10 +01:00
306 changed files with 5868 additions and 18389 deletions

View File

@@ -14,13 +14,13 @@ services:
windmill:
image: '${WINDMILL_IMAGE}'
privileged: false
restart: unless-stopped
ports:
- 8000:8000
entrypoint: ['/bin/sh', '-c']
command: 'echo ${OAUTH_JSON_BASE64} | base64 --decode > /usr/src/app/oauth.json && ./windmill'
environment:
- DATABASE_URL=postgres://postgres:changeme@localhost/windmill?sslmode=disable
- BASE_URL=${EXPECTED_URL}
- BASE_URL=http://localhost
- BASE_INTERNAL_URL=http://localhost:8000
- RUST_LOG=info
- NUM_WORKERS=3
@@ -28,16 +28,12 @@ services:
- DENO_PATH=/usr/bin/deno
- PYTHON_PATH=/usr/local/bin/python3
- METRICS_ADDR=false
- OAUTH_JSON_BASE64=${OAUTH_JSON_BASE64}
volumes:
- worker_dependency_cache:/tmp/windmill/cache
deploy:
resources:
limits:
memory: 250M
lsp:
image: '${LSP_IMAGE}'
restart: unless-stopped
ports:
- 3001:3001

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ 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

View File

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

View File

@@ -4,8 +4,6 @@ on:
types: [opened,synchronize,reopened,closed]
paths:
- "backend/**"
- ".github/uffizzi/**"
- ".github/workflows/**"
workflow_dispatch:
jobs:

View File

@@ -47,35 +47,22 @@ jobs:
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
cat event.json >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: Predict Deployment URL
id: url
# Replace dots in the repo name with the plus sign
run: |
REPO=$(echo ${{ github.repository }} | sed 's/\./+/g')
echo "EXPECTED_URL=https://app.uffizzi.com/github.com/$REPO/pull/$PR_NUMBER" >> $GITHUB_ENV
- name: Re-Render Compose File
run: |
OAUTH_JSON_BASE64=${{ secrets.OAUTH_JSON_BASE64 }}
export OAUTH_JSON_BASE64
envsubst '${OAUTH_JSON_BASE64} ${EXPECTED_URL}' < docker-compose.rendered.yml > docker-compose.uffizzi.yml
# cat docker-compose.uffizzi.yml
- name: Hash Rendered Compose File
id: hash
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.uffizzi.yml | awk '{ print $1 }')" >> $GITHUB_ENV
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
- name: Cache Rendered Compose File
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
uses: actions/cache@v3
with:
path: docker-compose.uffizzi.yml
path: docker-compose.rendered.yml
key: ${{ env.COMPOSE_FILE_HASH }}
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: DEBUG - Print Job Outputs
if: ${{ runner.debug }}
run: |
@@ -93,7 +80,7 @@ jobs:
# If this workflow was triggered by a PR close event, cache-key will be an empty string
# and this reusable workflow will delete the preview deployment.
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
compose-file-cache-path: docker-compose.uffizzi.yml
compose-file-cache-path: docker-compose.rendered.yml
server: https://app.uffizzi.com
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
permissions:

View File

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

View File

@@ -1,222 +1,6 @@
# Changelog
## [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)
### Bug Fixes
* **backend:** compile issue ([df8cc1f](https://github.com/windmill-labs/windmill/commit/df8cc1f2482b3d8b1530cdaef1361303ff5cadff))
## [1.61.0](https://github.com/windmill-labs/windmill/compare/v1.60.0...v1.61.0) (2023-01-31)
### Features
* add openapi viewer ([#1094](https://github.com/windmill-labs/windmill/issues/1094)) ([1337811](https://github.com/windmill-labs/windmill/commit/1337811438d48e23133f68e9157bd185d5fe4a82))
* add PIP_LOCAL_DEPENDENCIES ([b7db4c7](https://github.com/windmill-labs/windmill/commit/b7db4c78c4629f1fd2dfd7a338f783b16f07b24d))
* add QUEUE_LIMIT_WAIT_RESULT ([51a8810](https://github.com/windmill-labs/windmill/commit/51a8810aa0a9ab7702df459dd270278d42bd3899))
* add resource and resource type from json ([080ecb0](https://github.com/windmill-labs/windmill/commit/080ecb04d7a08d035fe07f179975b52bc0f77297))
* add sql as a valid type in Python ([0172587](https://github.com/windmill-labs/windmill/commit/0172587b129ce54d96dc99336a1f56c66ebdbef5))
* add sync webhook for flows ([f377c84](https://github.com/windmill-labs/windmill/commit/f377c84f5a2148a2bbb7c16e93f13e1d85ceb17e))
* **backend:** add queue_limit + configurable timeout + fix timeout cancel ([eef3bab](https://github.com/windmill-labs/windmill/commit/eef3bab6e4d9f1af1435db868c707a692558ab74))
* **deno:** add support for DENO_AUTH_TOKENS ([832ddab](https://github.com/windmill-labs/windmill/commit/832ddabdf2239521368e5f96df144abce0db31c2))
* **deno:** allow overriding deno sandboxing with DENO_FLAGS' ([7f40373](https://github.com/windmill-labs/windmill/commit/7f40373fd64005d87972854a565c6cf521232982))
* **frontend:** Add app inputs configurations ([#1142](https://github.com/windmill-labs/windmill/issues/1142)) ([3ed16b8](https://github.com/windmill-labs/windmill/commit/3ed16b88a42e4db6e12f8557c5bbaa2d832b1c17))
* **frontend:** Add app preview lock ([#1127](https://github.com/windmill-labs/windmill/issues/1127)) ([6a88e8c](https://github.com/windmill-labs/windmill/commit/6a88e8c4f4d6fa5c393ce27b2040784a74a73b06))
* **frontend:** Add copy button option to app text display component ([#1090](https://github.com/windmill-labs/windmill/issues/1090)) ([bdfc38d](https://github.com/windmill-labs/windmill/commit/bdfc38d954a3c5548fb7f9ee6f80f741eff8cb67))
* **frontend:** Add default codes to app editor ([#1099](https://github.com/windmill-labs/windmill/issues/1099)) ([c50c740](https://github.com/windmill-labs/windmill/commit/c50c7406f267b480af2a01b47e3fcfa1d763db7a))
* **frontend:** Add HTML result rendering ([#1160](https://github.com/windmill-labs/windmill/issues/1160)) ([c01bf70](https://github.com/windmill-labs/windmill/commit/c01bf70f62680a4b77812ac6eb64aca2b15d9a8d))
* **frontend:** Add more integration icons ([#1097](https://github.com/windmill-labs/windmill/issues/1097)) ([2191e85](https://github.com/windmill-labs/windmill/commit/2191e852318f069489f77a4f1c44aadf248c7f53))
* **frontend:** add plotly support ([a4f8f9e](https://github.com/windmill-labs/windmill/commit/a4f8f9e1cf80395d5cd1229c8dd5dda244e2ba7f))
* **frontend:** add selectedRowIndex to the table outputs ([#1145](https://github.com/windmill-labs/windmill/issues/1145)) ([f05f9e4](https://github.com/windmill-labs/windmill/commit/f05f9e4edb928e7a8e3e66a62de9c6487684a14b))
* **frontend:** Add Supabase resource ([#1107](https://github.com/windmill-labs/windmill/issues/1107)) ([12b00a8](https://github.com/windmill-labs/windmill/commit/12b00a808d1f12827a7bc26518cc6f972bdde917))
* **frontend:** add support for background scripts + add FormButtonCo… ([#1124](https://github.com/windmill-labs/windmill/issues/1124)) ([e969af9](https://github.com/windmill-labs/windmill/commit/e969af9e44d1b4409064080e8662552ee3e262e8))
* **frontend:** Add surreal db logo ([#1102](https://github.com/windmill-labs/windmill/issues/1102)) ([d811675](https://github.com/windmill-labs/windmill/commit/d81167588227f2cc433aab64551d96d21a589c5b))
* **frontend:** Add tooltip to app recompute ([#1122](https://github.com/windmill-labs/windmill/issues/1122)) ([4dfdf37](https://github.com/windmill-labs/windmill/commit/4dfdf374af358ef46ee8057373546719c6570067))
* **frontend:** add vega-lite component ([bd79938](https://github.com/windmill-labs/windmill/commit/bd79938bed6da3875a4a2dd72dad14dedbf25ddf))
* **frontend:** Display error as an icon in order to avoid clutter wh… ([#1143](https://github.com/windmill-labs/windmill/issues/1143)) ([22b8fed](https://github.com/windmill-labs/windmill/commit/22b8fed9d904a37aae66f6d957f4987f6ca9955c))
* **frontend:** Open debug runs from component ([#1155](https://github.com/windmill-labs/windmill/issues/1155)) ([73bc13b](https://github.com/windmill-labs/windmill/commit/73bc13bb7d4b1eb25a3a726ac9e6bb80120a495f))
* **frontend:** Update app table component styles ([#1100](https://github.com/windmill-labs/windmill/issues/1100)) ([172b5db](https://github.com/windmill-labs/windmill/commit/172b5dba8f4c3aaf11569c72313ad74845c668a6))
* **python:** add support for extra args in python ([772c768](https://github.com/windmill-labs/windmill/commit/772c768cda094f208a5efb7aab03eee3a8f38f68))
### Bug Fixes
* **frontend:** Add default value for text, number and date input + fix issues with number input + add date input in the settings panel ([#1135](https://github.com/windmill-labs/windmill/issues/1135)) ([8f90602](https://github.com/windmill-labs/windmill/commit/8f906026b3203702c3b6a30bcac9fb2aca985c29))
* **frontend:** Add highlight to selected workspace ([#1159](https://github.com/windmill-labs/windmill/issues/1159)) ([f221a6c](https://github.com/windmill-labs/windmill/commit/f221a6c17f145d0c42f7faf785c37f4037308973))
* **frontend:** add missing condition to properly select first row ([#1128](https://github.com/windmill-labs/windmill/issues/1128)) ([3d873ed](https://github.com/windmill-labs/windmill/commit/3d873ed51c769005981a8d8dfb95faa3ca33bb83))
* **frontend:** App form component display ([#1096](https://github.com/windmill-labs/windmill/issues/1096)) ([339742c](https://github.com/windmill-labs/windmill/commit/339742ca77dd0fda19d5a262617e42c341ef5871))
* **frontend:** App script list panel overflow ([#1101](https://github.com/windmill-labs/windmill/issues/1101)) ([7bc59d9](https://github.com/windmill-labs/windmill/commit/7bc59d9d2650b623a2b481a727ffc495b4216f22))
* **frontend:** App table action button cell ([#1149](https://github.com/windmill-labs/windmill/issues/1149)) ([e989662](https://github.com/windmill-labs/windmill/commit/e98966283dd9b57cc07da34876a90d19210c2927))
* **frontend:** App table header z-index ([#1120](https://github.com/windmill-labs/windmill/issues/1120)) ([59c4cc2](https://github.com/windmill-labs/windmill/commit/59c4cc2058f86deea793b61de59e2936e50e5577))
* **frontend:** Check if hiddenInlineScripts are undefined before iterating over them ([#1134](https://github.com/windmill-labs/windmill/issues/1134)) ([71a443e](https://github.com/windmill-labs/windmill/commit/71a443e3c56d2b8c951de6e3701a411ad1a0ce34))
* **frontend:** fix first row selection ([#1125](https://github.com/windmill-labs/windmill/issues/1125)) ([6c9daf7](https://github.com/windmill-labs/windmill/commit/6c9daf70021859dcd7cef717bc3acdfa88cffd02))
* **frontend:** Fix id generation when a second action ([#1110](https://github.com/windmill-labs/windmill/issues/1110)) ([4f86981](https://github.com/windmill-labs/windmill/commit/4f869811fee73826b2b10965241d2d8dba59dc2a))
* **frontend:** Make sure AppSelect items are an array ([#1144](https://github.com/windmill-labs/windmill/issues/1144)) ([24b1fa0](https://github.com/windmill-labs/windmill/commit/24b1fa0ae327c984841f9ed8b163b3fccc6da258))
* **frontend:** Make sure that old apps are rendering properly ([#1132](https://github.com/windmill-labs/windmill/issues/1132)) ([a78486d](https://github.com/windmill-labs/windmill/commit/a78486d7e08f76e22406063288b35e9030974d7a))
* **frontend:** Playwright ([#1108](https://github.com/windmill-labs/windmill/issues/1108)) ([f0435f5](https://github.com/windmill-labs/windmill/commit/f0435f5f81941c5b49500003aa27956d627daadb))
* **frontend:** Prepare app scripts code for export ([#1123](https://github.com/windmill-labs/windmill/issues/1123)) ([173093a](https://github.com/windmill-labs/windmill/commit/173093a40321f6ad35bf766a5554b21cea388771))
* **frontend:** Prevent modal from hijacking all keypress event ([#1136](https://github.com/windmill-labs/windmill/issues/1136)) ([aa6de3b](https://github.com/windmill-labs/windmill/commit/aa6de3bb5746b9d99c8e3a52e6a9fff10d97bc6a))
* **frontend:** Revert component input panel change ([#1092](https://github.com/windmill-labs/windmill/issues/1092)) ([0419e7e](https://github.com/windmill-labs/windmill/commit/0419e7e1c9239fd3cbc49acf82a73e9c01938153))
* **frontend:** Runnable table overflow ([#1119](https://github.com/windmill-labs/windmill/issues/1119)) ([462adbe](https://github.com/windmill-labs/windmill/commit/462adbe42f823646413a5003fd71f3dd473c0728))
* **frontend:** Select the first row by default, and remove the abilit… ([#1121](https://github.com/windmill-labs/windmill/issues/1121)) ([3c483f5](https://github.com/windmill-labs/windmill/commit/3c483f533759b9b4e589055dbddb31f294bea8fa))
* **frontend:** Show app builder header always on top ([#1118](https://github.com/windmill-labs/windmill/issues/1118)) ([631a3da](https://github.com/windmill-labs/windmill/commit/631a3da17f05a3d29defdf96a50d7e96a9f8baad))
* **frontend:** Update app scripts pane ([#1146](https://github.com/windmill-labs/windmill/issues/1146)) ([18f30c8](https://github.com/windmill-labs/windmill/commit/18f30c8286f8240158643ade8b0ef4607a80fbb0))
* **frontend:** Use absolute path on connect images ([#1095](https://github.com/windmill-labs/windmill/issues/1095)) ([43e069e](https://github.com/windmill-labs/windmill/commit/43e069eb96c0af7d3a1fe1db4f4b69f8e31e7438))
* improvements for error handling as first step of flow ([b77c239](https://github.com/windmill-labs/windmill/commit/b77c239f307a37777acb083b0cdb5c0d214a9dd8))
## [1.60.0](https://github.com/windmill-labs/windmill/compare/v1.59.0...v1.60.0) (2023-01-11)

View File

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

View File

@@ -85,7 +85,7 @@ COPY .git/ .git/
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features"
FROM python:3.11.2-slim-buster
FROM python:3.11.1-slim-buster
ARG APP=/usr/src/app

149
README.md
View File

@@ -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, SQL.
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.
</p>
<p align="center">
@@ -20,35 +20,55 @@ 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">
<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>
<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/>.
</p>
# Windmill - Turn scripts into workflows and UIs that you can share and run at scale
![Windmill Screenshot](./imgs/windmill-flow.png)
![Windmill Screenshot](./imgs/windmill.png)
Windmill is <b>fully open-sourced (AGPLv3)</b> and Windmill Labs offers dedicated instance and commercial support and licenses.
Windmill is <b>fully open-sourced (AGPLv3)</b>:
![Windmill Diagram](/imgs/stacks.svg)
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)
- [Windmill](#windmill)
- [Main Concepts](#main-concepts)
- [Show me some actual script code](#show-me-some-actual-script-code)
- [CLI](#cli)
- [Running scripts locally](#running-scripts-locally)
- [Layout](#layout)
- [Stack](#stack)
- [Security](#security)
- [Sandboxing](#sandboxing)
- [Sandboxing and workload isolation](#sandboxing-and-workload-isolation)
- [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](#oauth-for-self-hosting)
- [OAuth for self-hosting (very optional)](#oauth-for-self-hosting-very-optional)
- [Resource types](#resource-types)
- [Environment Variables](#environment-variables)
- [Run a local dev setup](#run-a-local-dev-setup)
@@ -79,49 +99,20 @@ 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 local files, github repos and to run scripts and flows on the instance from local commands. See
scripts from your own github repo. See
[more details](https://github.com/windmill-labs/windmill/tree/main/cli)
![CLI Screencast](./cli/vhs/output/setup.gif)
## Layout
### 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/>
- `backend/`: Rust backend
- `frontend`: Svelte frontend
- `lsp/`: Lsp asssistant for the monaco editor
- `<lang>-client/`: Windmill client for the given `<lang>`
## Stack
@@ -144,7 +135,7 @@ You can run your script locally easily, you simply need to pass the right enviro
## Security
### Sandboxing
### Sandboxing and workload isolation
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
@@ -170,23 +161,33 @@ 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/advanced/self_host)
[documentation](https://docs.windmill.dev/docs/how-tos/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 http://localhost et voilà :)
Go to https://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
@@ -194,14 +195,7 @@ 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>
### 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.
We publish helm charts at: <https://github.com/windmill-labs/windmill-helm-charts>
### Commercial license
@@ -213,14 +207,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
<ruben@windmill.dev> if you have any doubts.
<license@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
### OAuth for self-hosting (very optional)
To get the same oauth integrations as Windmill Cloud, mount `oauth.json` with
the following format:
@@ -237,13 +231,12 @@ 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/misc/contributing). We
[Contributor's guide](https://docs.windmill.dev/docs/contributors_guide). We
welcome contributions!
You may also add your own custom OAuth2 IdP and OAuth2 Resource provider:
@@ -275,18 +268,17 @@ 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). A setup script will prompt
you to have it being synced automatically everyday.
[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.
## Environment Variables
| Environment Variable name | Default | Description | Api Server/Worker/All |
| ------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| DATABASE_URL | | The Postgres database url. | All |
| DISABLE_NSJAIL | true | Disable Nsjail Sandboxing | Worker |
| 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 |
| 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 |
| 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 |
@@ -300,7 +292,8 @@ you to have it being synced automatically everyday.
| 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 | |
| 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 |
| 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 |
@@ -315,12 +308,10 @@ you to have it being synced automatically everyday.
| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker |
| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker |
| DENO_FLAGS | None | Override the flags passed to deno (default --allow-all) to tighten permissions. Minimum permissions needed are "--allow-read=args.json --allow-write=result.json" | Worker |
| PIP_LOCAL_DEPENDENCIES | None | Specify dependencies that are installed locally and do not need to be solved nor installed again | |
| ADDITIONAL_PYTHON_PATHS | None | Specify python paths (separated by a :) to be appended to the PYTHONPATH of the python jobs. To be used with PIP_LOCAL_DEPENDENCIES to use python codebases within Windmill | Worker |
| INCLUDE_HEADERS | None | Whitelist of headers that are passed to jobs as args (separated by a comma) | Server |
| WHITELIST_WORKSPACES | None | Whitelist of workspaces this worker takes job from | Worker |
| BLACKLIST_WORKSPACES | None | Blacklist of workspaces this worker takes job from | Worker |
| 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
@@ -367,4 +358,4 @@ running options.
## Copyright
Windmill Labs, Inc 2023
Windmill Labs, Inc 2022

763
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.69.0"
version = "1.60.0"
authors.workspace = true
edition.workspace = true
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "1.69.0"
version = "1.60.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -28,11 +28,7 @@ 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
@@ -87,7 +83,7 @@ chrono = { version = "^0", features = ["serde"] }
tracing = "^0"
tracing-subscriber = { version = "^0", features = ["env-filter", "json"] }
prometheus = { version = "^0", default-features = false }
cookie = { version = "0.17.0" }
cookie = { version = "^0" }
phf = { version = "0.11", features = ["macros"] }
rust-embed = "^6"
mime_guess = "^2"
@@ -125,7 +121,7 @@ async-recursion = "^1"
swc_common = "^0"
swc_ecma_parser = "^0"
swc_ecma_ast = "^0"
base64 = "0.21.0"
base64 = "^0"
unicode-general-category = "^0"
hmac = "0.12.1"
sha2 = "0.10.6"
@@ -148,8 +144,4 @@ 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_zip = { version = "0.0.11", features = ["full"] }
async-stripe = { version = "0.14", features = ["runtime-tokio-hyper", "checkout"] }

View File

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

View File

@@ -1,12 +0,0 @@
-- 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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,16 +0,0 @@
-- 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';

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,10 @@ use serde_json::json;
use windmill_common::error;
use windmill_parser::{json_to_typ, Arg, MainArgSignature, Typ};
use rustpython_parser as parser;
use rustpython_parser::ast::{Constant, ExprKind, Located, StmtKind};
use rustpython_parser::{
ast::{Constant, ExprKind, Located, StmtKind},
parser,
};
const DEF_MAIN: &str = "def main(";
const FUNCTION_CALL: &str = "<function call>";
@@ -78,7 +80,7 @@ pub fn parse_python_signature(code: &str) -> error::Result<MainArgSignature> {
let def_arg_start = params.args.len() - params.defaults.len();
Ok(MainArgSignature {
star_args: params.vararg.is_some(),
star_kwargs: params.kwarg.is_some(),
star_kwargs: params.vararg.is_some(),
args: params
.args
.into_iter()
@@ -134,10 +136,7 @@ fn to_value(et: &ExprKind) -> Option<serde_json::Value> {
.into_iter()
.zip(values)
.map(|(k, v)| {
let key = k
.as_ref()
.map(|x| x.node.clone())
.and_then(|n| to_value(&n))
let key = to_value(&k.node)
.and_then(|x| match x {
serde_json::Value::String(s) => Some(s),
_ => None,
@@ -183,7 +182,6 @@ static PYTHON_IMPORTS_REPLACEMENT: phf::Map<&'static str, &'static str> = phf_ma
"f" => "requests",
"shopify" => "ShopifyAPI",
"seleniumwire" => "selenium-wire",
"openbb-terminal" => "openbb[all]",
};
fn replace_import(x: String) -> String {
@@ -195,13 +193,13 @@ fn replace_import(x: String) -> String {
}
lazy_static! {
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)$").unwrap();
static ref RE: Regex = Regex::new(r"^\#(\S+)$").unwrap();
}
pub fn parse_python_imports(code: &str) -> error::Result<Vec<String>> {
let find_requirements = code
.lines()
.find_position(|x| x.starts_with("#requirements:") || x.starts_with("# requirements:"));
.find_position(|x| x.starts_with("#requirements:"));
if let Some((pos, _)) = find_requirements {
let lines = code
.lines()

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,9 @@ use std::net::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;
mod ee;
@@ -27,7 +26,7 @@ async fn main() -> anyhow::Result<()> {
let num_workers = std::env::var("NUM_WORKERS")
.ok()
.and_then(|x| x.parse::<i32>().ok())
.unwrap_or(DEFAULT_NUM_WORKERS as i32);
.unwrap_or(windmill_common::DEFAULT_NUM_WORKERS as i32);
let metrics_addr: Option<SocketAddr> = std::env::var("METRICS_ADDR")
.ok()
@@ -39,13 +38,6 @@ async fn main() -> anyhow::Result<()> {
.transpose()?
.flatten();
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())
@@ -60,25 +52,58 @@ async fn main() -> anyhow::Result<()> {
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], port));
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string());
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);
if server_mode || num_workers > 0 {
let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
let base_url2 = base_url.clone();
let server_f = async {
if server_mode {
windmill_api::run_server(db.clone(), addr, rx.resubscribe()).await?;
windmill_api::run_server(db.clone(), addr, base_url, 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}
Windmill Enterprise Edition {GIT_VERSION} LICENSE_KEY: {license_key:?}, S3_CACHE_BUCKET: {sync_bucket:?}
##############################"
);
);
#[cfg(not(feature = "enterprise"))]
tracing::info!(
@@ -88,58 +113,38 @@ 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",
"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",
]);
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(),
rx.resubscribe(),
addr,
timeout,
num_workers,
base_internal_url.clone(),
sleep_queue,
WorkerConfig {
disable_nsjail,
disable_nuser,
base_internal_url,
base_url,
keep_job_dir,
},
rx.resubscribe(),
sync_bucket,
license_key,
)
.await?;
}
Ok(()) as anyhow::Result<()>
};
let base_url = base_url2;
let monitor_f = async {
if server_mode {
monitor_db(&db, rx.resubscribe(), &base_internal_url);
monitor_db(&db, timeout, base_url, rx.resubscribe());
}
Ok(()) as anyhow::Result<()>
};
@@ -158,46 +163,34 @@ 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, rx, &base_internal_url).await
windmill_worker::handle_zombie_jobs_periodically(&db1, timeout, &base_url, rx).await
});
tokio::spawn(async move { windmill_api::delete_expired_items_perdiodically(&db2, rx2).await });
}
pub async fn run_workers(
db: Pool<Postgres>,
rx: tokio::sync::broadcast::Receiver<()>,
addr: SocketAddr,
timeout: i32,
num_workers: i32,
base_internal_url: String,
sleep_queue: u64,
worker_config: WorkerConfig,
rx: tokio::sync::broadcast::Receiver<()>,
mut periodic_script: Option<String>,
license_key: Option<String>,
) -> anyhow::Result<()> {
let license_key = std::env::var("LICENSE_KEY").ok();
#[cfg(feature = "enterprise")]
ee::verify_license_key(license_key)?;
@@ -205,6 +198,12 @@ 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();
@@ -224,17 +223,22 @@ 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 base_internal_url = base_internal_url.clone();
let worker_config = worker_config.clone();
let wp = periodic_script.take();
handles.push(tokio::spawn(monitor.instrument(async move {
tracing::info!(worker = %worker_name, "starting worker");
tracing::info!(addr = %addr.to_string(), 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
})));

View File

@@ -6,8 +6,10 @@ 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;
@@ -87,7 +89,14 @@ impl ApiServer {
let addr = sock.local_addr().unwrap();
drop(sock);
let task = tokio::task::spawn(windmill_api::run_server(db.clone(), addr, rx));
let task = tokio::task::spawn({
windmill_api::run_server(
db.clone(),
addr,
format!("http://localhost:{}", addr.port()),
rx,
)
});
return Self { addr, tx, task };
}
@@ -908,20 +917,43 @@ 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
};

View File

@@ -8,8 +8,12 @@ edition.workspace = true
name = "windmill_api"
path = "src/lib.rs"
[[bin]]
name = "windmill_api"
path = "src/main.rs"
[features]
enterprise = ["windmill-queue/enterprise", "async-stripe"]
enterprise = ["windmill-queue/enterprise"]
[dependencies]
windmill-queue.workspace = true
@@ -65,7 +69,6 @@ hmac.workspace = true
cookie.workspace = true
sha2.workspace = true
urlencoding.workspace = true
async-stripe = { workspace = true, optional = true }
async-stripe.workspace = true
lazy_static.workspace = true
prometheus.workspace = true
async_zip.workspace = true

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.69.0
version: 1.60.0
title: Windmill API
contact:
@@ -1092,10 +1092,6 @@ paths:
- variable
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: already_encrypted
in: query
schema:
type: boolean
requestBody:
description: new variable
required: true
@@ -1137,10 +1133,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- name: already_encrypted
in: query
schema:
type: boolean
requestBody:
description: updated variable
required: true
@@ -2482,44 +2474,6 @@ paths:
application/json:
schema: {}
/w/{workspace}/jobs/run_wait_result/f/{path}:
post:
summary: run flow by path and wait until completion
operationId: runWaitResultFlowByPath
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- name: scheduled_for
description: when to schedule this job (leave empty for immediate run)
in: query
schema:
type: string
format: date-time
- name: scheduled_in_secs
description: schedule the script to execute in the number of seconds starting now
in: query
schema:
type: integer
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
requestBody:
description: script args
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ScriptArgs"
responses:
"200":
description: job result
content:
application/json:
schema: {}
/w/{workspace}/jobs/result_by_id/{flow_job_id}/{node_id}:
get:
summary: get job result by id
@@ -2716,23 +2670,6 @@ 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
@@ -3317,8 +3254,6 @@ paths:
type: boolean
new_logs:
type: string
mem_peak:
type: integer
/w/{workspace}/jobs/completed/get/{id}:
get:
@@ -4817,8 +4752,6 @@ components:
type: string
visible_to_owner:
type: boolean
mem_peak:
type: integer
required:
- id
- running
@@ -4905,8 +4838,6 @@ components:
type: string
visible_to_owner:
type: boolean
mem_peak:
type: integer
required:
- id
- created_by

View File

@@ -12,8 +12,7 @@ use crate::{
jobs::script_path_to_payload,
users::{require_owner_of_path, Authed, OptAuthed},
variables::build_crypt,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
webhook_util::{WebhookMessage, WebhookUtil},
};
use axum::{
extract::{Extension, Json, Path, Query},
@@ -22,6 +21,7 @@ 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};
@@ -170,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 = ?".bind(&w_id))
.and_where("app.workspace_id = ? OR app.workspace_id = 'starter'".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
@@ -311,7 +311,7 @@ async fn get_secret_id(
async fn create_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
@@ -360,17 +360,20 @@ async fn create_app(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateApp { workspace: w_id, path: app.path.clone() },
);
webhook.send_message(WebhookMessage::CreateApp {
workspace: w_id.clone(),
path: app.path.clone(),
});
Ok((StatusCode::CREATED, app.path))
}
async fn list_hub_apps(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
async fn list_hub_apps(
Authed { email, .. }: Authed,
Extension(http_client): Extension<Client>,
) -> JsonResult<serde_json::Value> {
let flows = list_elems_from_hub(
&HTTP_CLIENT,
http_client,
"https://hub.windmill.dev/searchUiData?approved=true",
&email,
)
@@ -381,9 +384,10 @@ async fn list_hub_apps(Authed { email, .. }: Authed) -> JsonResult<serde_json::V
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,
@@ -398,7 +402,7 @@ pub async fn get_hub_app_by_id(
async fn delete_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -422,10 +426,8 @@ async fn delete_app(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::DeleteApp { workspace: w_id, path: path.to_owned() },
);
webhook
.send_message(WebhookMessage::DeleteApp { workspace: w_id.clone(), path: path.to_owned() });
Ok(format!("app {} deleted", path))
}
@@ -433,7 +435,7 @@ async fn delete_app(
async fn update_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
@@ -523,14 +525,11 @@ 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(),
},
);
webhook.send_message(WebhookMessage::UpdateApp {
workspace: w_id.clone(),
old_path: path.to_owned(),
new_path: npath.clone(),
});
Ok(format!("app {} updated (npath: {:?})", path, npath))
}

View File

@@ -7,11 +7,12 @@
*/
use hyper::StatusCode;
use reqwest::Client;
use sql_builder::prelude::*;
use axum::{
extract::{Extension, Path, Query},
routing::{delete, get, post},
routing::{get, post},
Json, Router,
};
use sql_builder::SqlBuilder;
@@ -31,8 +32,7 @@ use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
webhook_util::{WebhookMessage, WebhookUtil},
};
pub fn workspaced_service() -> Router {
@@ -41,7 +41,6 @@ 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))
@@ -82,7 +81,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 = ?".bind(&w_id))
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
@@ -112,9 +111,12 @@ async fn list_flows(
Ok(Json(rows))
}
async fn list_hub_flows(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
async fn list_hub_flows(
Authed { email, .. }: Authed,
Extension(http_client): Extension<Client>,
) -> JsonResult<serde_json::Value> {
let flows = list_elems_from_hub(
&HTTP_CLIENT,
http_client,
"https://hub.windmill.dev/searchFlowData?approved=true",
&email,
)
@@ -143,9 +145,10 @@ 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,
@@ -179,7 +182,7 @@ async fn check_path_conflict<'c>(
async fn create_flow(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path(w_id): Path<String>,
Json(nf): Json<NewFlow>,
) -> Result<(StatusCode, String)> {
@@ -220,10 +223,10 @@ async fn create_flow(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() },
);
webhook.send_message(WebhookMessage::CreateFlow {
workspace: w_id.clone(),
path: nf.path.clone(),
});
let tx = user_db.begin(&authed).await?;
let (dependency_job_uuid, mut tx) = push(
@@ -283,7 +286,7 @@ async fn update_flow(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Json(nf): Json<NewFlow>,
) -> Result<String> {
@@ -372,14 +375,11 @@ 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(),
},
);
webhook.send_message(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(
@@ -427,12 +427,13 @@ 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")
.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 OR workspace_id = 'starter')",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut tx)
.await?;
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
@@ -446,7 +447,8 @@ 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)",
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id \
= 'starter'))",
path,
w_id
)
@@ -460,7 +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>,
webhook: WebhookUtil,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -485,50 +487,14 @@ 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() },
);
webhook.send_message(WebhookMessage::ArchiveFlow {
workspace: w_id.clone(),
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 {

View File

@@ -9,7 +9,7 @@
use crate::{
db::{UserDB, DB},
users::Authed,
webhook_util::{WebhookMessage, WebhookShared},
webhook_util::{WebhookMessage, WebhookUtil},
};
use axum::{
extract::{Extension, Path, Query},
@@ -140,7 +140,7 @@ async fn check_name_conflict<'c>(
async fn create_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
@@ -196,10 +196,10 @@ async fn create_folder(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFolder { workspace: w_id, name: ng.name.clone() },
);
webhook.send_message(WebhookMessage::CreateFolder {
workspace: w_id.clone(),
name: ng.name.clone(),
});
Ok(format!("Created folder {}", ng.name))
}
@@ -251,7 +251,7 @@ pub async fn require_is_owner(
async fn update_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
Json(ng): Json<UpdateFolder>,
) -> Result<String> {
@@ -306,10 +306,10 @@ async fn update_folder(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.to_owned() },
);
webhook.send_message(WebhookMessage::UpdateFolder {
workspace: w_id.clone(),
name: name.to_owned(),
});
Ok(format!("Updated folder {}", name))
}
@@ -427,7 +427,7 @@ async fn get_folder_usage(
async fn delete_folder(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
@@ -453,10 +453,8 @@ async fn delete_folder(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteFolder { workspace: w_id, name: name.clone() },
);
webhook
.send_message(WebhookMessage::DeleteFolder { workspace: w_id.clone(), name: name.clone() });
Ok(format!("delete folder at name {}", name))
}
@@ -465,7 +463,7 @@ async fn add_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner }): Json<Owner>,
) -> Result<String> {
@@ -497,10 +495,8 @@ async fn add_owner(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
webhook
.send_message(WebhookMessage::UpdateFolder { workspace: w_id.clone(), name: name.clone() });
Ok(format!("Added {} to folder {}", owner, name))
}
@@ -535,7 +531,7 @@ async fn remove_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
Json(Owner { owner }): Json<Owner>,
) -> Result<String> {
@@ -567,10 +563,8 @@ async fn remove_owner(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateFolder { workspace: w_id, name: name.clone() },
);
webhook
.send_message(WebhookMessage::UpdateFolder { workspace: w_id.clone(), name: name.clone() });
Ok(format!("Removed {} to folder {}", owner, name))
}

View File

@@ -6,6 +6,8 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::sync::Arc;
use anyhow::Context;
use axum::{
extract::{FromRequest, Json, Path, Query},
@@ -36,7 +38,7 @@ use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed},
variables::get_workspace_key,
BASE_URL,
BaseUrl, QueueLimitWaitResult, TimeoutWaitResult,
};
pub fn workspaced_service() -> Router {
@@ -51,10 +53,6 @@ pub fn workspaced_service() -> Router {
"/run_wait_result/h/:hash",
post(run_wait_result_job_by_hash),
)
.route(
"/run_wait_result/f/*script_path",
post(run_wait_result_flow_by_path),
)
.route("/run/h/:hash", post(run_job_by_hash))
.route("/run/preview", post(run_preview_job))
.route("/run/preview_flow", post(run_preview_flow_job))
@@ -105,7 +103,9 @@ async fn get_result_by_id(
Query(ResultByIdQuery { skip_direct }): Query<ResultByIdQuery>,
Path((w_id, flow_id, node_id)): Path<(String, String, String)>,
) -> windmill_common::error::JsonResult<serde_json::Value> {
tracing::error!("get_result_by_id_bef: {:?} {:?}", flow_id, node_id);
let res = windmill_queue::get_result_by_id(db, skip_direct, w_id, flow_id, node_id).await?;
tracing::error!("get_result_by_id: {:?}", res);
Ok(Json(res))
}
@@ -152,7 +152,8 @@ 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",
"select path from script where hash = $1 AND (workspace_id = $2 OR workspace_id = \
'starter')",
hash,
w_id
)
@@ -255,8 +256,6 @@ 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)]
@@ -269,14 +268,6 @@ pub struct RunJobQuery {
queue_limit: Option<i64>,
}
lazy_static::lazy_static! {
static ref INCLUDE_HEADERS: Vec<String> = std::env::var("INCLUDE_HEADERS")
.ok().map(|x| x
.split(',')
.map(|s| s.to_string())
.collect()).unwrap_or_default();
}
impl RunJobQuery {
async fn get_scheduled_for<'c>(
&self,
@@ -297,14 +288,11 @@ impl RunJobQuery {
headers: HeaderMap,
mut args: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let whitelist = self
.include_header
self.include_header
.as_ref()
.map(|s| s.split(",").map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
whitelist
.unwrap_or_default()
.iter()
.chain(INCLUDE_HEADERS.iter())
.for_each(|h| {
if let Some(v) = headers.get(h) {
args.insert(
@@ -471,7 +459,7 @@ async fn list_jobs(
"running",
"script_hash",
"script_path",
"CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END",
"args",
"null as duration_ms",
"null as success",
"false as deleted",
@@ -486,7 +474,6 @@ async fn list_jobs(
"email",
"visible_to_owner",
"suspend",
"mem_peak",
],
);
let sqlc = list_completed_jobs_query(
@@ -506,7 +493,7 @@ async fn list_jobs(
"null as running",
"script_hash",
"script_path",
"CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END",
"args",
"duration_ms",
"success",
"deleted",
@@ -521,7 +508,6 @@ async fn list_jobs(
"email",
"visible_to_owner",
"null as suspend",
"mem_peak",
],
);
let sql = format!(
@@ -922,16 +908,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}"
@@ -999,7 +985,6 @@ struct UnifiedJob {
email: String,
visible_to_owner: bool,
suspend: Option<i32>,
mem_peak: Option<i32>,
}
impl From<UnifiedJob> for Job {
@@ -1034,7 +1019,6 @@ 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,
@@ -1067,7 +1051,6 @@ impl From<UnifiedJob> for Job {
email: uj.email,
visible_to_owner: uj.visible_to_owner,
suspend: uj.suspend,
mem_peak: uj.mem_peak,
}),
t => panic!("job type {} not valid", t),
}
@@ -1312,26 +1295,18 @@ 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_WAIT_RESULT.or(run_query.queue_limit)).await?;
check_queue_too_long(db, queue_limit.0.or(run_query.queue_limit)).await?;
let script_path = script_path.to_path();
let mut tx = user_db.clone().begin(&authed).await?;
let job_payload = script_path_to_payload(script_path, &mut tx, &w_id).await?;
@@ -1361,7 +1336,7 @@ pub async fn run_wait_result_job_by_path(
run_wait_result(
authed,
Extension(user_db),
*TIMEOUT_WAIT_RESULT,
timeout.0,
uuid,
Path((w_id, script_path)),
)
@@ -1372,6 +1347,7 @@ 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,
@@ -1407,59 +1383,13 @@ pub async fn run_wait_result_job_by_hash(
run_wait_result(
authed,
Extension(user_db),
*TIMEOUT_WAIT_RESULT,
timeout.0,
uuid,
Path((w_id, script_hash)),
)
.await
}
pub async fn run_wait_result_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
headers: HeaderMap,
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
) -> error::JsonResult<serde_json::Value> {
check_queue_too_long(db, run_query.queue_limit).await?;
let flow_path = flow_path.to_path();
let mut tx = user_db.clone().begin(&authed).await?;
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
let args = run_query.add_include_headers(headers, args.unwrap_or_default());
let (uuid, tx) = push(
tx,
&w_id,
JobPayload::Flow(flow_path.to_string()),
args,
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
scheduled_for,
None,
run_query.parent_job,
false,
false,
None,
!run_query.invisible_to_owner.unwrap_or(false),
)
.await?;
tx.commit().await?;
run_wait_result(
authed,
Extension(user_db),
*TIMEOUT_WAIT_RESULT,
uuid,
Path((w_id, flow_path)),
)
.await
}
// a similar function exists on the worker
pub async fn script_path_to_payload<'c>(
script_path: &str,
@@ -1592,7 +1522,6 @@ 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(
@@ -1602,8 +1531,8 @@ async fn get_job_update(
) -> error::JsonResult<JobUpdate> {
let mut tx = db.begin().await?;
let record = sqlx::query!(
"SELECT substr(logs, $1) as logs, mem_peak FROM queue WHERE workspace_id = $2 AND id = $3",
let logs = query_scalar!(
"SELECT substr(logs, $1) as logs FROM queue WHERE workspace_id = $2 AND id = $3",
log_offset,
&w_id,
&id
@@ -1611,13 +1540,12 @@ async fn get_job_update(
.fetch_optional(&mut tx)
.await?;
if let Some(record) = record {
if let Some(logs) = logs {
tx.commit().await?;
Ok(Json(JobUpdate {
running: if !running { Some(true) } else { None },
completed: None,
new_logs: record.logs,
mem_peak: record.mem_peak,
new_logs: logs,
}))
} else {
let logs = query_scalar!(
@@ -1635,7 +1563,6 @@ async fn get_job_update(
running: Some(false),
completed: Some(true),
new_logs: logs,
mem_peak: record.map(|r| r.mem_peak).flatten(),
}))
}
}
@@ -1753,7 +1680,6 @@ async fn list_completed_jobs(
"is_skipped",
"email",
"visible_to_owner",
"mem_peak",
],
)
.sql()?;

View File

@@ -6,24 +6,22 @@
* 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::utils::rd_string;
use windmill_common::{error::to_anyhow, utils::rd_string};
use crate::{
db::UserDB,
oauth2::{build_oauth_clients, SlackVerifier},
tracing_init::{MyMakeSpan, MyOnResponse},
users::{Authed, OptAuthed},
webhook_util::WebhookShared,
webhook_util::{WebhookShared, WebhookUtil},
};
mod apps;
@@ -52,32 +50,20 @@ 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());
@@ -87,7 +73,16 @@ 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()
@@ -98,8 +93,23 @@ 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(Extension(WebhookShared::new(rx.resubscribe(), db.clone())));
.layer(Extension(WebhookShared::new(rx.resubscribe())));
// build our application with a route
let app = Router::new()
.nest(
@@ -109,7 +119,21 @@ pub async fn run_server(
"/w/:workspace_id",
Router::new()
.nest("/scripts", scripts::workspaced_service())
.nest("/jobs", jobs::workspaced_service())
.nest(
"/jobs",
jobs::workspaced_service()
.layer(Extension(Arc::new(TimeoutWaitResult(
std::env::var("TIMEOUT_WAIT_RESULT")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(20),
))))
.layer(Extension(Arc::new(QueueLimitWaitResult(
std::env::var("QUEUE_LIMIT_WAIT_RESULT")
.ok()
.and_then(|x| x.parse().ok()),
)))),
)
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),
@@ -126,7 +150,8 @@ pub async fn run_server(
.nest("/flows", flows::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/folders", folders::workspaced_service()),
.nest("/folders", folders::workspaced_service())
.route_layer(from_extractor::<WebhookUtil>()),
)
.nest("/workspaces", workspaces::global_service())
.nest(
@@ -150,7 +175,10 @@ pub async fn run_server(
"/auth",
users::make_unauthed_service().layer(Extension(argon2)),
)
.nest("/oauth", oauth2::global_service())
.nest(
"/oauth",
oauth2::global_service().layer(Extension(slack_verifier)),
)
.route("/version", get(git_v))
.route("/openapi.yaml", get(openapi)),
)

View File

@@ -0,0 +1,70 @@
/*
* 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(())
}

View File

@@ -1,69 +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;
// 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(())
// }

View File

@@ -8,6 +8,8 @@
use std::{collections::HashMap, fmt::Debug};
use std::sync::Arc;
use anyhow::Context;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
@@ -27,25 +29,26 @@ 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, NEW_USER_WEBHOOK};
use crate::users::{truncate_token, Authed};
use crate::workspaces::invite_user_to_all_auto_invite_worspaces;
use crate::{
db::{UserDB, DB},
variables::{build_crypt, encrypt},
workspaces::WorkspaceSettings,
BaseUrl,
};
use crate::{BASE_URL, HTTP_CLIENT, IS_SECURE, OAUTH_CLIENTS, SLACK_SIGNING_SECRET};
use crate::{CookieDomain, IsSecure};
use windmill_common::error::{self, to_anyhow, Error};
use windmill_common::oauth2::*;
use windmill_queue::JobPayload;
use std::{fs, str};
use std::str;
pub fn global_service() -> Router {
Router::new()
@@ -108,7 +111,7 @@ pub struct AllClients {
pub slack: Option<OClient>,
}
pub fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
pub async 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"
))?;
@@ -116,12 +119,14 @@ pub fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
"../../oauth_login.json"
))?;
let mut content = String::new();
let path = "./oauth.json";
let content = if std::path::Path::new(path).exists() {
fs::read_to_string(path).map_err(to_anyhow)?
if std::path::Path::new(path).exists() {
let mut file = File::open(path).await?;
file.read_to_string(&mut content).await?;
} else {
"{}".to_string()
};
content.push_str("{}");
}
let oauths: HashMap<String, OAuthClient> =
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
@@ -283,10 +288,12 @@ 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 = &OAUTH_CLIENTS.connects;
let connects = &clients.connects;
let scopes = query
.get("scopes")
.map(|x| x.split('+').map(|x| x.to_owned()).collect());
@@ -302,7 +309,7 @@ async fn connect(
cookies,
scopes,
extra_params,
*IS_SECURE,
is_secure.0,
)
}
@@ -369,9 +376,11 @@ async fn delete_account(
Ok(format!("Deleted account id {id}"))
}
async fn list_logins() -> error::JsonResult<Vec<String>> {
async fn list_logins(
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<Vec<String>> {
Ok(Json(
OAUTH_CLIENTS
clients
.logins
.keys()
.map(|x| x.to_owned())
@@ -384,9 +393,11 @@ struct ScopesAndParams {
scopes: Vec<String>,
extra_params: Option<HashMap<String, String>>,
}
async fn list_connects() -> error::JsonResult<HashMap<String, ScopesAndParams>> {
async fn list_connects(
Extension(clients): Extension<Arc<AllClients>>,
) -> error::JsonResult<HashMap<String, ScopesAndParams>> {
Ok(Json(
(&OAUTH_CLIENTS.connects)
(&clients.connects)
.into_iter()
.map(|(k, v)| {
(
@@ -401,8 +412,12 @@ async fn list_connects() -> error::JsonResult<HashMap<String, ScopesAndParams>>
))
}
async fn connect_slack(cookies: Cookies) -> error::Result<Redirect> {
let mut client = OAUTH_CLIENTS
async fn connect_slack(
Extension(clients): Extension<Arc<AllClients>>,
Extension(is_secure): Extension<Arc<IsSecure>>,
cookies: Cookies,
) -> error::Result<Redirect> {
let mut client = clients
.slack
.as_ref()
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
@@ -413,7 +428,7 @@ async fn connect_slack(cookies: Cookies) -> error::Result<Redirect> {
client.add_scope("commands");
let url = client.authorize_url(&state);
set_cookie(&state, cookies, *IS_SECURE);
set_cookie(&state, cookies, is_secure.0);
Ok(Redirect::to(url.as_str()))
}
@@ -455,9 +470,14 @@ async fn disconnect_slack(
Ok(format!("slack disconnected"))
}
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)
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)
}
#[derive(Deserialize)]
@@ -468,11 +488,13 @@ 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).await?;
_refresh_token(tx, &path, w_id, id, clients, http_client).await?;
Ok(format!("Token at path {path} refreshed"))
}
@@ -482,6 +504,8 @@ 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",
@@ -491,14 +515,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 = (&OAUTH_CLIENTS
let client = (&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).await;
let token = _exchange_token(client, &account.refresh_token, http_client).await;
if let Err(token_err) = token {
sqlx::query!(
@@ -556,10 +580,14 @@ pub async fn _refresh_token<'c>(
Ok(token_str)
}
async fn _exchange_token(client: OClient, refresh_token: &str) -> Result<TokenResponse, Error> {
async fn _exchange_token(
client: OClient,
refresh_token: &str,
http_client: Client,
) -> 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)?;
@@ -580,9 +608,11 @@ 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 = OAUTH_CLIENTS
let client_w_scopes = &clients
.connects
.get(&client_name)
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?;
@@ -590,7 +620,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))
@@ -601,15 +631,17 @@ 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 = OAUTH_CLIENTS
let client = 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?;
@@ -625,26 +657,14 @@ 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, display_name, owners, extra_perms)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
(workspace_id, name, owners, extra_perms)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
&w_id,
"slack_bot",
"Slack bot",
&["g/slack".to_string()],
serde_json::json!({"g/slack": true})
&[],
serde_json::json!({})
)
.execute(&mut tx)
.await?;
@@ -727,14 +747,16 @@ 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_SIGNING_SECRET
if slack_verifier
.as_ref()
.as_ref()
.map(|sv| sv.verify(&ts, &body, &sig).ok())
@@ -792,7 +814,7 @@ async fn slack_command(
)
.await?;
tx.commit().await?;
let url = BASE_URL.to_owned();
let url = base_url.0.to_owned();
return Ok(format!(
"Job launched. See details at {url}/run/{uuid}?workspace={}",
&settings.workspace_id
@@ -817,27 +839,31 @@ 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 = &OAUTH_CLIENTS
let client_w_config = &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,
)
@@ -873,7 +899,15 @@ 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).await?;
crate::users::create_session_token(
&email,
super_admin,
&mut tx,
cookies,
is_secure.0,
&cookie_domain.as_ref().0,
)
.await?;
} else {
return Err(error::Error::BadRequest(format!(
"an user with the email associated to this login exists but with a different \
@@ -908,7 +942,15 @@ 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).await?;
crate::users::create_session_token(
&email,
false,
&mut tx,
cookies,
is_secure.0,
&cookie_domain.as_ref().0,
)
.await?;
audit_log(
&mut tx,
&email,
@@ -919,7 +961,6 @@ 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)
@@ -941,16 +982,6 @@ 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!(
@@ -1072,7 +1103,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(Some(cookie::SameSite::Lax));
cookie.set_same_site(cookie::SameSite::Lax);
cookie.set_http_only(true);
cookie.set_path("/");
cookies.add(cookie);

View File

@@ -9,7 +9,7 @@
use crate::{
db::{UserDB, DB},
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
webhook_util::{WebhookMessage, WebhookUtil},
};
use axum::{
extract::{Extension, Path, Query},
@@ -142,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 = ?".bind(&w_id))
.and_where("resource.workspace_id = ? OR resource.workspace_id = 'starter'".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
@@ -187,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",
WHERE resource.path = $1 AND (resource.workspace_id = $2 OR resource.workspace_id = 'starter')",
path.to_owned(),
&w_id
)
@@ -226,7 +226,8 @@ 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",
"SELECT value from resource WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
'starter')",
path.to_owned(),
&w_id
)
@@ -263,7 +264,7 @@ async fn check_path_conflict<'c>(
async fn create_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path(w_id): Path<String>,
Json(resource): Json<CreateResource>,
) -> Result<(StatusCode, String)> {
@@ -294,10 +295,10 @@ async fn create_resource(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateResource { workspace: w_id, path: resource.path.clone() },
);
webhook.send_message(WebhookMessage::CreateResource {
workspace: w_id.clone(),
path: resource.path.clone(),
});
Ok((
StatusCode::CREATED,
@@ -308,7 +309,7 @@ async fn create_resource(
async fn delete_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -340,10 +341,10 @@ async fn delete_resource(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteResource { workspace: w_id, path: path.to_owned() },
);
webhook.send_message(WebhookMessage::DeleteResource {
workspace: w_id.clone(),
path: path.to_owned(),
});
Ok(format!("resource {} deleted", path))
}
@@ -351,7 +352,7 @@ async fn delete_resource(
async fn update_resource(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditResource>,
@@ -413,14 +414,11 @@ 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(),
},
);
webhook.send_message(WebhookMessage::UpdateResource {
workspace: w_id.clone(),
old_path: path.to_owned(),
new_path: npath.clone(),
});
Ok(format!("resource {} updated (npath: {:?})", path, npath))
}
@@ -433,7 +431,7 @@ struct UpdateResource {
async fn update_resource_value(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, path)): Path<(String, StripPath)>,
Json(nv): Json<UpdateResource>,
) -> Result<String> {
@@ -459,14 +457,11 @@ 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(),
},
);
webhook.send_message(WebhookMessage::UpdateResource {
workspace: w_id.clone(),
old_path: path.to_owned(),
new_path: path.to_owned(),
});
Ok(format!("value of resource {} updated", path))
}
@@ -477,7 +472,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 = 'admins') ORDER \
"SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') ORDER \
BY name",
&w_id
)
@@ -492,7 +487,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 = 'admins') \
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') \
ORDER BY name",
&w_id
)
@@ -511,7 +506,8 @@ 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 = 'admins')",
"SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
'starter' OR workspace_id = 'admins')",
&name,
&w_id
)
@@ -528,7 +524,8 @@ async fn exists_resource_type(
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins'))",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
'starter' OR workspace_id = 'admins'))",
name,
w_id
)
@@ -542,7 +539,7 @@ async fn exists_resource_type(
async fn create_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path(w_id): Path<String>,
Json(resource_type): Json<CreateResourceType>,
) -> Result<(StatusCode, String)> {
@@ -573,10 +570,7 @@ async fn create_resource_type(
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateResourceType { name: resource_type.name.clone() },
);
webhook.send_message(WebhookMessage::CreateResourceType { name: resource_type.name.clone() });
Ok((
StatusCode::CREATED,
@@ -609,7 +603,7 @@ async fn check_rt_path_conflict<'c>(
async fn delete_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
@@ -634,10 +628,7 @@ async fn delete_resource_type(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteResourceType { name: name.clone() },
);
webhook.send_message(WebhookMessage::DeleteResourceType { name: name.clone() });
Ok(format!("resource_type {} deleted", name))
}
@@ -645,7 +636,7 @@ async fn delete_resource_type(
async fn update_resource_type(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, name)): Path<(String, String)>,
Json(ns): Json<EditResourceType>,
) -> Result<String> {
@@ -675,10 +666,7 @@ async fn update_resource_type(
)
.await?;
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateResourceType { name: name.clone() },
);
webhook.send_message(WebhookMessage::UpdateResourceType { name: name.clone() });
Ok(format!("resource_type {} updated", name))
}

View File

@@ -6,6 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
use reqwest::Client;
use sql_builder::prelude::*;
use windmill_audit::{audit_log, ActionKind};
@@ -13,8 +14,7 @@ use crate::{
db::{UserDB, DB},
schedule::clear_schedule,
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
webhook_util::{WebhookMessage, WebhookUtil},
};
use axum::{
extract::{Extension, Path, Query},
@@ -111,7 +111,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 = ?".bind(&w_id))
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
@@ -120,7 +120,7 @@ async fn list_scripts(
sqlb.and_where_eq(
"created_at",
"(select max(created_at) from script where o.path = path
AND workspace_id = ?)"
AND (workspace_id = ? OR workspace_id = 'starter'))"
.bind(&w_id),
);
} else {
@@ -163,9 +163,12 @@ async fn list_scripts(
Ok(Json(rows))
}
async fn list_hub_scripts(Authed { email, .. }: Authed) -> JsonResult<serde_json::Value> {
async fn list_hub_scripts(
Authed { email, .. }: Authed,
Extension(http_client): Extension<Client>,
) -> JsonResult<serde_json::Value> {
let asks = list_elems_from_hub(
&HTTP_CLIENT,
http_client,
"https://hub.windmill.dev/searchData?approved=true",
&email,
)
@@ -182,7 +185,7 @@ fn hash_script(ns: &NewScript) -> i64 {
async fn create_script(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(ns): Json<NewScript>,
@@ -399,14 +402,11 @@ 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(),
},
);
webhook.send_message(WebhookMessage::UpdateScript {
workspace: w_id.clone(),
path: ns.path.clone(),
hash: hash.to_string(),
});
} else {
audit_log(
&mut tx,
@@ -424,14 +424,11 @@ async fn create_script(
),
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateScript {
workspace: w_id,
path: ns.path.clone(),
hash: hash.to_string(),
},
);
webhook.send_message(WebhookMessage::CreateScript {
workspace: w_id.clone(),
path: ns.path.clone(),
hash: hash.to_string(),
});
}
tx.commit().await?;
@@ -439,16 +436,21 @@ async fn create_script(
Ok((StatusCode::CREATED, format!("{}", hash)))
}
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_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_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?,
))
}
@@ -461,9 +463,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 \
"SELECT * 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)",
(workspace_id = $2 OR workspace_id = 'starter'))",
)
.bind(path)
.bind(w_id)
@@ -502,12 +504,11 @@ 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 \
"SELECT content 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 archived = false AND \
workspace_id = $2)",
path,
w_id
(workspace_id = $2 OR workspace_id = 'starter'))",
path, w_id
)
.fetch_optional(&mut tx)
.await?;
@@ -524,8 +525,10 @@ 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 AND
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND workspace_id = $2))",
"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')))",
path,
w_id
)
@@ -541,12 +544,13 @@ 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")
.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 OR workspace_id = 'starter')",
)
.bind(hash)
.bind(workspace_id)
.fetch_optional(db)
.await?;
let script = not_found_if_none(script_o, "Script", hash.to_string())?;
Ok(script)
@@ -592,7 +596,8 @@ 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",
"SELECT lock, lock_error_logs FROM script WHERE hash = $1 AND (workspace_id = $2 OR \
workspace_id = 'starter')",
hash.0,
w_id,
)
@@ -607,7 +612,7 @@ async fn get_deployment_status(
async fn archive_script_by_path(
authed: Authed,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -634,10 +639,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() },
);
webhook.send_message(WebhookMessage::DeleteScript {
workspace: w_id.clone(),
hash: hash.to_string(),
});
Ok(())
}
@@ -645,7 +650,7 @@ async fn archive_script_by_path(
async fn archive_script_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script> {
let mut tx = user_db.begin(&authed).await?;
@@ -670,10 +675,10 @@ 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() },
);
webhook.send_message(WebhookMessage::DeleteScript {
workspace: w_id.clone(),
hash: hash.to_string(),
});
Ok(Json(script))
}
@@ -681,7 +686,7 @@ async fn archive_script_by_hash(
async fn delete_script_by_hash(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Extension(db): Extension<DB>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script> {
@@ -710,10 +715,10 @@ 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() },
);
webhook.send_message(WebhookMessage::DeleteScript {
workspace: w_id.clone(),
hash: hash.to_string(),
});
Ok(Json(script))
}

View File

@@ -9,41 +9,58 @@
use axum::{
body::{self, BoxBody},
extract::OriginalUri,
http::{header, Response},
http::{header, response::Builder, Response},
response::IntoResponse,
Extension,
};
use hyper::Uri;
use crate::{CloudHosted, ContentSecurityPolicy, IsSecure};
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(OriginalUri(original_uri): OriginalUri) -> StaticFile {
StaticFile(original_uri)
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)
}
#[derive(RustEmbed)]
#[folder = "../../frontend/build/"]
struct Asset;
pub struct StaticFile(Uri);
pub struct StaticFile(
pub String,
pub bool,
pub bool,
pub Arc<ContentSecurityPolicy>,
);
impl IntoResponse for StaticFile {
fn into_response(self) -> Response<BoxBody> {
let path = self.0.path().trim_start_matches('/');
serve_path(path)
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)
}
}
const TWO_HUNDRED: &str = "200.html";
fn serve_path(path: &str) -> Response<BoxBody> {
fn serve_path(
path: String,
can_set_security_headers: bool,
csp: Arc<ContentSecurityPolicy>,
) -> Response<BoxBody> {
if path.starts_with("api/") {
return Response::builder()
.status(404)
.body(body::boxed(body::Empty::new()))
.unwrap();
}
match Asset::get(path) {
match Asset::get(path.as_str()) {
Some(content) => {
let body = body::boxed(body::Full::from(content.data));
let mime = mime_guess::from_path(path).first_or_octet_stream();
@@ -58,12 +75,26 @@ fn serve_path(path: &str) -> Response<BoxBody> {
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.starts_with("_app/") => Response::builder()
None if path.as_str().starts_with("_app/") => Response::builder()
.status(404)
.body(body::boxed(body::Empty::new()))
.unwrap(),
None => serve_path(TWO_HUNDRED),
None => serve_path("200.html".to_owned(), can_set_security_headers, csp),
}
}
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
}

View File

@@ -13,7 +13,7 @@ use crate::{
folders::get_folders_for_user,
utils::require_super_admin,
workspaces::invite_user_to_all_auto_invite_worspaces,
COOKIE_DOMAIN, HTTP_CLIENT, IS_SECURE,
CookieDomain, IsSecure,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
@@ -720,12 +720,14 @@ 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);
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
let domain = cookie_domain.0.clone();
if domain.is_some() {
cookie.set_domain(domain.clone().unwrap());
}
cookies.remove(cookie);
let mut tx = db.begin().await?;
@@ -1268,10 +1270,6 @@ 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>,
@@ -1307,16 +1305,6 @@ 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": "new_user"}))
.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)))
@@ -1561,19 +1549,21 @@ 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, bool)> = sqlx::query_as(
"SELECT email, password_hash, super_admin, first_time_user FROM password WHERE email = $1 AND login_type = \
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 = \
'password'",
)
.bind(&email)
.fetch_optional(&mut tx)
.await?;
if let Some((email, hash, super_admin, first_time_user)) = email_w_h {
if let Some((email, hash, super_admin)) = email_w_h {
let parsed_hash =
PasswordHash::new(&hash).map_err(|e| Error::InternalErr(e.to_string()))?;
if argon2
@@ -1582,27 +1572,15 @@ async fn login(
{
Err(Error::BadRequest("Invalid login".to_string()))
} else {
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?;
let token = create_session_token(
&email,
super_admin,
&mut tx,
cookies,
is_secure.0,
&cookie_domain.as_ref().0,
)
.await?;
tx.commit().await?;
Ok(token)
}
@@ -1616,6 +1594,8 @@ 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!(
@@ -1631,12 +1611,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(Some(cookie::SameSite::Lax));
cookie.set_secure(is_secure);
cookie.set_same_site(cookie::SameSite::Lax);
cookie.set_http_only(true);
cookie.set_path(COOKIE_PATH);
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
if domain.is_some() {
cookie.set_domain(domain.clone().unwrap());
}
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
expire += time::Duration::days(3);

View File

@@ -6,11 +6,14 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::sync::Arc;
use crate::{
db::{UserDB, DB},
oauth2::_refresh_token,
oauth2::{AllClients, _refresh_token},
users::{require_owner_of_path, Authed},
webhook_util::{WebhookMessage, WebhookShared},
webhook_util::{WebhookMessage, WebhookUtil},
BaseUrl,
};
/*
* Author: Ruben Fiszel
@@ -34,6 +37,7 @@ use windmill_common::{
};
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
use reqwest::Client;
use serde::Deserialize;
use sqlx::{Postgres, Transaction};
@@ -50,6 +54,7 @@ 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(
@@ -60,6 +65,7 @@ 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()),
@@ -85,7 +91,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 ORDER BY path",
WHERE variable.workspace_id = $1 OR (is_secret IS NOT TRUE AND variable.workspace_id = 'starter') ORDER BY path",
)
.bind(&w_id)
.fetch_all(&mut tx)
@@ -105,6 +111,8 @@ 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?;
@@ -116,7 +124,8 @@ 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
WHERE variable.path = $1 AND (variable.workspace_id = $2 OR (is_secret IS NOT TRUE AND \
variable.workspace_id = 'starter'))
LIMIT 1",
)
.bind(&path)
@@ -142,7 +151,17 @@ 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()).await?)
Some(
_refresh_token(
tx,
&variable.path,
w_id,
variable.account.unwrap(),
clients,
http_client,
)
.await?,
)
} else if !value.is_empty() && decrypt_secret {
let mc = build_crypt(&mut tx, &w_id).await?;
tx.commit().await?;
@@ -206,15 +225,14 @@ async fn check_path_conflict<'c>(
async fn create_variable(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
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 && !already_encrypted.unwrap_or(false) {
let value = if variable.is_secret {
let mc = build_crypt(&mut tx, &w_id).await?;
encrypt(&mc, &variable.value)
} else {
@@ -249,10 +267,10 @@ async fn create_variable(
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateVariable { workspace: w_id, path: variable.path.clone() },
);
webhook.send_message(WebhookMessage::CreateVariable {
workspace: w_id.clone(),
path: variable.path.clone(),
});
Ok((
StatusCode::CREATED,
@@ -263,7 +281,7 @@ async fn create_variable(
async fn delete_variable(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
webhook: WebhookUtil,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -296,10 +314,10 @@ async fn delete_variable(
tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteVariable { workspace: w_id, path: path.to_owned() },
);
webhook.send_message(WebhookMessage::DeleteVariable {
workspace: w_id.clone(),
path: path.to_owned(),
});
Ok(format!("variable {} deleted", path))
}
@@ -312,18 +330,12 @@ 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>,
webhook: WebhookUtil,
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::*;
@@ -349,7 +361,7 @@ async fn update_variable(
.await?
.unwrap_or(false);
let value = if is_secret && !already_encrypted.unwrap_or(false) {
let value = if is_secret {
let mc = build_crypt(&mut tx, &w_id).await?;
encrypt(&mc, &nvalue)
} else {
@@ -407,14 +419,11 @@ 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(),
},
);
webhook.send_message(WebhookMessage::UpdateVariable {
workspace: w_id.clone(),
old_path: path.to_owned(),
new_path: npath.clone(),
});
Ok(format!("variable {} updated (npath: {:?})", path, npath))
}

View File

@@ -1,7 +1,14 @@
use std::time::Duration;
use axum::{
async_trait,
extract::{FromRequestParts, OriginalUri},
http::request::Parts,
Extension,
};
use hyper::StatusCode;
use serde::Serialize;
use tokio::{select, sync::mpsc, time::interval};
use tokio::{select, sync::mpsc};
use crate::db::DB;
@@ -24,7 +31,6 @@ pub enum WebhookMessage {
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 },
@@ -48,7 +54,7 @@ pub struct WebhookShared {
}
impl WebhookShared {
pub fn new(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, db: DB) -> Self {
pub fn new(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<(String, WebhookMessage)>();
let _process = tokio::spawn(async move {
let client = reqwest::Client::builder()
@@ -56,58 +62,95 @@ impl WebhookShared {
.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);
}
Some((url, message)) => {
let timer = WEBHOOK_REQUEST_COUNT.start_timer();
let _ = client.post(url).json(&message).send().await;
timer.stop_and_record();
},
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));
#[derive(Clone)]
pub struct WebhookUtil {
webhook: Option<String>,
shared: Extension<WebhookShared>,
}
impl WebhookUtil {
pub fn send_message(&self, message: WebhookMessage) {
let Some(webhook) = &self.webhook else {
return;
};
let _ = self.shared.channel.send((webhook.clone(), message));
}
}
#[async_trait]
impl<S> FromRequestParts<S> for WebhookUtil
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let original_uri = OriginalUri::from_request_parts(parts, state)
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else {
None
};
let webhook = sqlx::query_scalar!(
"SELECT webhook FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_one(
&Extension::<DB>::from_request_parts(parts, state)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Could not aquire DB while retrieving webhook".to_owned(),
)
})?
.0,
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Could not execute DB query {:?}", e),
)
})?;
let shared = Extension::<WebhookShared>::from_request_parts(parts, state)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Could not aquire shared process while retrieving webhook".to_owned(),
)
})?;
Ok(Self { webhook, shared })
}
}

View File

@@ -6,31 +6,24 @@
* LICENSE-AGPL for a copy of the license.
*/
#[cfg(enterprise)]
use std::str::FromStr;
use std::{str::FromStr, sync::Arc};
#[cfg(enterprise)]
use crate::BASE_URL;
use crate::{
apps::AppWithLastVersion,
db::{UserDB, DB},
folders::Folder,
resources::{Resource, ResourceType},
users::{Authed, WorkspaceInvite, NEW_USER_WEBHOOK},
users::{Authed, WorkspaceInvite},
utils::require_super_admin,
HTTP_CLIENT,
BaseUrl,
};
#[cfg(enterprise)]
use axum::response::Redirect;
use axum::{
body::StreamBody,
extract::{Extension, Path, Query},
headers,
response::IntoResponse,
response::{IntoResponse, Redirect},
routing::{delete, get, post},
Json, Router,
};
#[cfg(enterprise)]
use stripe::CustomerId;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
@@ -49,7 +42,7 @@ use tokio::fs::File;
use tokio_util::io::ReaderStream;
pub fn workspaced_service() -> Router {
let router = Router::new()
Router::new()
.route("/list_pending_invites", get(list_pending_invites))
.route("/update", post(edit_workspace))
.route("/archive", post(archive_workspace))
@@ -61,16 +54,9 @@ pub fn workspaced_service() -> Router {
.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));
#[cfg(enterprise)]
let router = {
router
.route("/checkout", get(stripe_checkout))
.route("/billing_portal", get(stripe_portal));
};
router
.route("/premium_info", get(premium_info))
.route("/checkout", get(stripe_checkout))
.route("/billing_portal", get(stripe_portal))
}
pub fn global_service() -> Router {
Router::new()
@@ -230,63 +216,52 @@ async fn premium_info(
Ok(Json(row))
}
#[cfg(enterprise)]
#[derive(Deserialize)]
struct PlanQuery {
plan: String,
}
#[cfg(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);
let failure_rd = format!("{}/workspace_settings/checkout?success=false", *BASE_URL);
let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url.0);
let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url.0);
let checkout_session = {
let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd);
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.line_items = match plan.plan.as_str() {
"team" => Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: Some(1),
price: Some("price_1MUlrWGU3NdFi9eLE9GBZhoY".to_string()),
quantity: None,
price: Some("price_1MSdSyGU3NdFi9eLMdV6cS6F".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlreGU3NdFi9eLi6sOyvVa".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlrlGU3NdFi9eLFLggSXZV".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MUlr3GU3NdFi9eLbZYFjR9p".to_string()),
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
..Default::default()
},
]),
"enterprise" => Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
..Default::default()
},
]),
// "enterprise" => Some(vec![
// stripe::CreateCheckoutSessionLineItems {
// quantity: None,
// price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()),
// ..Default::default()
// },
// stripe::CreateCheckoutSessionLineItems {
// quantity: None,
// price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
// ..Default::default()
// },
// ]),
_ => Err(Error::BadRequest("invalid plan".to_string()))?,
};
params.customer_email = Some(&authed.email);
@@ -302,11 +277,11 @@ async fn stripe_checkout(
}
}
#[cfg(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!(
@@ -317,7 +292,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);
let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
let portal_session = {
let customer_id = CustomerId::from_str(&customer_id).unwrap();
let mut params = stripe::CreateBillingPortalSession::new(customer_id);
@@ -676,19 +651,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
@@ -961,15 +936,6 @@ 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": "new_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),
@@ -1065,118 +1031,20 @@ 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 = 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 name = format!("windmill-{w_id}.tar");
let file_path = tmp_dir.path().join(&name);
let file = File::create(&file_path).await?;
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 mut a = tokio_tar::Builder::new(file);
{
let folders = sqlx::query_as::<_, Folder>("SELECT * FROM folder WHERE workspace_id = $1")
.bind(&w_id)
@@ -1184,12 +1052,12 @@ async fn tarball_workspace(
.await?;
for folder in folders {
archive
.write_to_archive(
&to_string_without_metadata(&folder, true).unwrap(),
&format!("f/{}/folder.meta.json", folder.name),
)
.await?;
write_to_archive(
serde_json::to_string_pretty(&folder).unwrap(),
format!("f/{}/folder.meta.json", folder.name),
&mut a,
)
.await?;
}
}
@@ -1210,9 +1078,7 @@ async fn tarball_workspace(
ScriptLang::Go => "go",
ScriptLang::Bash => "sh",
};
archive
.write_to_archive(&script.content, &format!("{}.{}", script.path, ext))
.await?;
write_to_archive(script.content, format!("{}.{}", script.path, ext), &mut a).await?;
let lock = script
.lock
@@ -1228,9 +1094,7 @@ async fn tarball_workspace(
lock,
};
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
archive
.write_to_archive(&metadata_str, &format!("{}.script.json", script.path))
.await?;
write_to_archive(metadata_str, format!("{}.script.json", script.path), &mut a).await?;
}
}
@@ -1244,10 +1108,13 @@ async fn tarball_workspace(
.await?;
for resource in resources {
let resource_str = &to_string_without_metadata(&resource, false).unwrap();
archive
.write_to_archive(&resource_str, &format!("{}.resource.json", resource.path))
.await?;
let resource_str = serde_json::to_string_pretty(&resource).unwrap();
write_to_archive(
resource_str,
format!("{}.resource.json", resource.path),
&mut a,
)
.await?;
}
}
@@ -1261,13 +1128,13 @@ async fn tarball_workspace(
.await?;
for resource_type in resource_types {
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?;
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?;
}
}
@@ -1280,49 +1147,25 @@ async fn tarball_workspace(
.await?;
for flow in flows {
let flow_str = &to_string_without_metadata(&flow, false).unwrap();
archive
.write_to_archive(&flow_str, &format!("{}.flow.json", flow.path))
.await?;
let flow_str = serde_json::to_string_pretty(&flow).unwrap();
write_to_archive(flow_str, format!("{}.flow.json", flow.path), &mut a).await?;
}
}
{
let variables = sqlx::query_as::<_, ExportableListableVariable>(
"SELECT *, false as is_expired FROM variable WHERE workspace_id = $1",
"SELECT *, false as is_expired FROM variable WHERE workspace_id = $1 AND is_secret = false",
)
.bind(&w_id)
.fetch_all(&db)
.await?;
for var in variables {
let var_str = &to_string_without_metadata(&var, false).unwrap();
archive
.write_to_archive(&var_str, &format!("{}.variable.json", var.path))
.await?;
let flow_str = serde_json::to_string_pretty(&var).unwrap();
write_to_archive(flow_str, format!("{}.variable.json", var.path), &mut a).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?;
a.into_inner().await?;
let file = tokio::fs::File::open(file_path).await?;
@@ -1339,3 +1182,20 @@ 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(())
}

View File

@@ -41,4 +41,3 @@ 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

View File

@@ -59,7 +59,6 @@ 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)]
@@ -153,9 +152,7 @@ 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>,
@@ -248,9 +245,7 @@ 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,
},

View File

@@ -26,13 +26,12 @@ 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;
@@ -124,8 +123,10 @@ 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 AND
created_at = (SELECT max(created_at) FROM script WHERE path = $1 AND workspace_id = $2) AND
"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
deleted = false",
script_path,
w_id

View File

@@ -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},

View File

@@ -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,

View File

@@ -8,8 +8,6 @@
use serde::{Deserialize, Serialize};
use crate::BASE_URL;
#[derive(Serialize, Clone)]
pub struct ContextualVariable {
@@ -68,6 +66,7 @@ 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>,
@@ -115,7 +114,7 @@ pub fn get_reserved_variables(
},
ContextualVariable {
name: "WM_BASE_URL".to_string(),
value: BASE_URL.clone(),
value: base_url.to_string(),
description: "base url of this instance".to_string(),
},
ContextualVariable {

View File

@@ -9,7 +9,6 @@
use std::{collections::HashMap, str::FromStr};
use anyhow::Context;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres, Transaction};
use tracing::{instrument, Instrument};
@@ -17,7 +16,7 @@ use ulid::Ulid;
use uuid::Uuid;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{self, Error},
error::{self, to_anyhow, 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},
@@ -25,10 +24,6 @@ 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",
@@ -50,7 +45,7 @@ lazy_static::lazy_static! {
}
const MAX_FREE_EXECS: i32 = 1000;
const MAX_FREE_CONCURRENT_RUNS: i32 = 15;
const MAX_FREE_CONCURRENT_RUNS: i32 = 3;
pub async fn cancel_job<'c>(
username: &str,
@@ -87,32 +82,7 @@ pub async fn cancel_job<'c>(
Ok((tx, job_option))
}
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(",")
));
}
pub async fn pull(db: &Pool<Postgres>) -> windmill_common::error::Result<Option<QueuedJob>> {
/* Jobs can be started if they:
* - haven't been started before,
* running = false
@@ -120,7 +90,7 @@ pub async fn pull(
* 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>(&format!(
let job: Option<QueuedJob> = sqlx::query_as::<_, QueuedJob>(
"UPDATE queue
SET running = true
, started_at = coalesce(started_at, now())
@@ -129,17 +99,17 @@ pub async fn pull(
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()))) {workspaces_filter}
OR suspend_until <= now()))
ORDER BY scheduled_for
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *"
))
RETURNING *",
)
.fetch_optional(db)
.await?;
@@ -389,7 +359,8 @@ 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",
"SELECT language as \"language: ScriptLang\" FROM script WHERE hash = $1 AND \
(workspace_id = $2 OR workspace_id = 'starter')",
hash.0,
workspace_id
)
@@ -410,7 +381,7 @@ pub async fn push<'c>(
)
}
JobPayload::ScriptHub { path } => {
let script = get_hub_script(&HTTP_CLIENT, path.clone(), email)
let script = get_hub_script(path.clone(), email)
.await
.context("error fetching hub script")?;
(
@@ -440,10 +411,11 @@ 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",
path,
workspace_id
)
"SELECT value FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
'starter')",
path,
workspace_id
)
.fetch_optional(&mut tx)
.await?
.ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", path)))?;
@@ -466,7 +438,8 @@ 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",
"SELECT value FROM flow WHERE path = $1 AND (workspace_id = $2 OR workspace_id = \
'starter')",
flow,
workspace_id
)
@@ -612,14 +585,17 @@ 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(
client: &reqwest::Client,
path: String,
email: &str,
) -> error::Result<HubScript> {
get_full_hub_script_by_path(email, StripPath(path), client)
.await
.map(|e| e)
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)
}
#[derive(Debug, sqlx::FromRow, Serialize, Clone)]
@@ -670,8 +646,6 @@ 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>,
}
impl QueuedJob {

View File

@@ -3,10 +3,11 @@ name = "windmill-worker"
version.workspace = true
authors.workspace = true
edition.workspace = true
default-run = "worker"
[lib]
name = "windmill_worker"
path = "src/lib.rs"
[[bin]]
name = "worker"
path = "./src/main.rs"
[features]
default = []

View File

@@ -77,12 +77,6 @@ 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!(
@@ -115,10 +109,9 @@ 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, $29)
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $28)
ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)",
queued_job.workspace_id,
queued_job.id,
@@ -147,8 +140,7 @@ pub async fn add_completed_job(
queued_job.language: ScriptLang,
duration: Option<i64>,
queued_job.email,
queued_job.visible_to_owner,
mem_peak
queued_job.visible_to_owner
)
.execute(&mut tx)
.await

View File

@@ -34,11 +34,10 @@ pub async fn eval_timeout(
env: Vec<(String, serde_json::Value)>,
creds: Option<EvalCreds>,
by_id: Option<IdContext>,
base_internal_url: &str,
base_internal_url: String,
) -> 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),
tokio::task::spawn_blocking(move || {
@@ -308,6 +307,19 @@ async fn op_get_id(args: Vec<String>) -> Result<Option<serde_json::Value>, anyho
let node_id = &args[4];
let client = windmill_api_client::create_client(base_url, token.clone());
let err = client
.result_by_id(workspace, flow_job_id, node_id, Some(true))
.await
.err()
.unwrap();
let res = match err {
windmill_api_client::Error::UnexpectedResponse(e) => {
tracing::error!("{:?}", e.text().await);
anyhow::anyhow!("bar")
}
_ => anyhow::anyhow!("foo"),
};
tracing::error!("{:?}", res);
let result = client
.result_by_id(workspace, flow_job_id, node_id, Some(true))
.await
@@ -347,7 +359,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, String::new().as_str()).await?;
let res = eval(&mut runtime, code, env, None, None, "").await?;
assert_eq!(res, json!(4));
Ok(())
}
@@ -360,7 +372,7 @@ mod tests {
multiline template`";
let mut runtime = JsRuntime::new(RuntimeOptions::default());
let res = eval(&mut runtime, code, env, None, None, String::new().as_str()).await?;
let res = eval(&mut runtime, code, env, None, None, "").await?;
assert_eq!(res, json!("my 5\nmultiline template"));
Ok(())
}
@@ -373,7 +385,7 @@ multiline template`";
];
let code = r#"params.test"#;
let res = eval_timeout(code.to_string(), env, None, None, String::new().as_str()).await?;
let res = eval_timeout(code.to_string(), env, None, None, "".to_string()).await?;
assert_eq!(res, json!(2));
Ok(())
}

View File

@@ -0,0 +1,138 @@
/*
* 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(())
}

View File

@@ -1,92 +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 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

View File

@@ -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, KEEP_JOB_DIR};
use crate::worker;
use anyhow::Context;
use async_recursion::async_recursion;
use dyn_iter::DynIter;
@@ -50,8 +50,9 @@ pub async fn update_flow_status_after_job_completion(
unrecoverable: bool,
same_worker_tx: Sender<Uuid>,
worker_dir: &str,
stop_early_override: Option<bool>,
keep_job_dir: bool,
base_internal_url: &str,
stop_early_override: Option<bool>,
) -> error::Result<()> {
tracing::debug!("UPDATE FLOW STATUS: {flow:?} {success} {result:?} {w_id}");
@@ -120,7 +121,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(), None, None, base_internal_url)
compute_bool_from_expr(expr, &r.args, result.clone(), base_internal_url, None, None)
.await?
} else {
false
@@ -480,7 +481,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;
}
@@ -497,12 +498,13 @@ 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?);
}
@@ -557,7 +559,7 @@ async fn has_failure_module<'c>(
flow: Uuid,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
) -> Result<bool, Error> {
sqlx::query_scalar::<_, Option<bool>>(
sqlx::query_scalar(
"
SELECT raw_flow->'failure_module' != 'null'::jsonb
FROM queue
@@ -568,7 +570,6 @@ 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)> {
@@ -582,9 +583,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(
@@ -597,7 +598,7 @@ async fn compute_bool_from_expr(
.into(),
creds,
by_id,
base_internal_url,
base_internal_url.to_string(),
)
.await?
{
@@ -721,7 +722,7 @@ async fn transform_input(
context,
Some(EvalCreds { workspace: workspace.to_string(), token: token.to_string() }),
Some(by_id.clone()),
base_internal_url,
base_internal_url.to_string(),
)
.await
.map_err(|e| {
@@ -768,8 +769,8 @@ pub async fn handle_flow(
client,
last_result,
same_worker_tx,
worker_dir,
base_internal_url,
worker_dir,
)
.await?;
Ok(())
@@ -785,8 +786,8 @@ async fn push_next_flow_job(
client: &windmill_api_client::Client,
mut last_result: serde_json::Value,
same_worker_tx: Sender<Uuid>,
worker_dir: &str,
base_internal_url: &str,
worker_dir: &str,
) -> error::Result<()> {
let mut i = usize::try_from(status.step)
.with_context(|| format!("invalid module index {}", status.step))?;
@@ -815,8 +816,9 @@ async fn push_next_flow_job(
true,
same_worker_tx,
worker_dir,
None,
false,
base_internal_url,
None,
)
.await;
}
@@ -856,7 +858,7 @@ async fn push_next_flow_job(
.into(),
None,
None,
base_internal_url,
"".to_string(),
)
.await
.map_err(|e| {
@@ -1160,8 +1162,8 @@ async fn push_next_flow_job(
&status,
&status_module,
last_result.clone(),
previous_id,
base_internal_url,
previous_id,
)
.await?;
tx.commit().await?;
@@ -1502,8 +1504,8 @@ async fn compute_next_flow_transform<'c>(
status: &FlowStatus,
status_module: &FlowStatusModule,
last_result: serde_json::Value,
previous_id: String,
base_internal_url: &str,
previous_id: String,
) -> error::Result<(sqlx::Transaction<'c, sqlx::Postgres>, NextFlowTransform)> {
match &module.value {
FlowModuleValue::Identity => Ok((
@@ -1699,12 +1701,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?;
@@ -1918,7 +1920,7 @@ where
vars(),
Some(EvalCreds { workspace, token }),
by_id,
base_internal_url,
base_internal_url.to_string(),
)
.await
}

View File

@@ -28,25 +28,12 @@ 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.
## Switch to a different workspace
### Pushing a folder
```
wmill workspace switch <workspace_name>
```
## Sync a workspace
### Pull
```
wmill sync pull
```
### Push
```
wmill sync push
```
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.
### Pushing individual files

View File

@@ -1,105 +0,0 @@
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 }),
);
}
}

View File

@@ -1,12 +1,10 @@
// deno-lint-ignore-file no-explicit-any
import { colors, GlobalUserInfo, setClient, UserService } from "./deps.ts";
import { loginInteractive, tryGetLoginInfo } from "./login.ts";
import { colors, setClient } from "./deps.ts";
import { tryGetLoginInfo } from "./login.ts";
import { GlobalOptions } from "./types.ts";
import {
addWorkspace,
getActiveWorkspace,
getWorkspaceByName,
removeWorkspace,
Workspace,
} from "./workspace.ts";
@@ -60,7 +58,7 @@ export async function resolveWorkspace(
}
}
export async function requireLogin(opts: GlobalOptions): Promise<GlobalUserInfo> {
export async function requireLogin(opts: GlobalOptions) {
const workspace = await resolveWorkspace(opts);
let token = await tryGetLoginInfo(opts);
@@ -69,27 +67,6 @@ export async function requireLogin(opts: GlobalOptions): Promise<GlobalUserInfo>
}
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);
workspace.token = newToken;
addWorkspace(workspace);
setClient(
token,
workspace.remote.substring(0, workspace.remote.length - 1),
);
return await UserService.globalWhoami();
}
}
export async function tryResolveVersion(

View File

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

View File

@@ -1,41 +1,32 @@
// windmill
export { setClient } from "https://deno.land/x/windmill@v1.66.0/mod.ts";
export * from "https://deno.land/x/windmill@v1.66.0/windmill-api/index.ts";
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";
// cliffy
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 { 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 {
DenoLandProvider,
UpgradeCommand,
} from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
} from "https://deno.land/x/cliffy@v0.25.6/command/upgrade/mod.ts";
// std
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 { 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 {
copy,
readAll,
readerFromStreamReader,
} 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";
} from "https://deno.land/std@0.170.0/streams/mod.ts";
export { DelimiterStream } from "https://deno.land/std@0.170.0/streams/mod.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";

View File

@@ -1,136 +1,16 @@
// deno-lint-ignore-file no-explicit-any
import {
Difference,
GlobalOptions,
PushDiffs,
Resource,
setValueByPath,
} from "./types.ts";
import { GlobalOptions } from "./types.ts";
import {
colors,
Command,
Flow,
FlowService,
JobService,
microdiff,
OpenFlowWPath,
OpenFlow,
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;
@@ -142,7 +22,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 pushed"));
console.log(colors.bold.underline.green("Flow successfully pushed"));
}
export async function pushFlow(
@@ -150,10 +30,38 @@ export async function pushFlow(
workspace: string,
remotePath: string,
) {
const data = decoverto.type(FlowFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, remotePath);
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,
},
});
}
}
async function list(opts: GlobalOptions & { showArchived?: boolean }) {
@@ -178,14 +86,16 @@ async function list(opts: GlobalOptions & { showArchived?: boolean }) {
}
new Table()
.header(["path", "summary", "edited by"])
.header(["path", "summary", "edited at", "edited by"])
.padding(2)
.border(true)
.body(
total.map((x) => [
x.path,
x.summary,
x.edited_at,
x.edited_by,
x.description ?? "-",
]),
)
.render();

View File

@@ -1,121 +1,6 @@
import { colors, Command, Folder, FolderService, microdiff } from "./deps.ts";
import { colors, Command, Folder, FolderService } from "./deps.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.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,
},
});
}
}
}
import { GlobalOptions } from "./types.ts";
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
@@ -133,18 +18,64 @@ 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 pushed"));
console.log(colors.bold.underline.green("Resource successfully pushed"));
}
type FolderFile = {
owners: Array<string> | undefined;
extra_perms: Record<string, boolean> | undefined;
};
export async function pushFolder(
workspace: string,
filePath: string,
remotePath: string,
) {
const data = decoverto.type(FolderFile).rawToInstance(
await Deno.readTextFile(filePath),
);
data.push(workspace, remotePath);
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 command = new Command()

View File

@@ -1,6 +1,6 @@
import { Command } from "./deps.ts";
import { requireLogin, resolveWorkspace } from "./context.ts";
import { ResourceTypeFile } from "./resource-type.ts";
import { pushResourceTypeDef } from "./resource-type.ts";
import { GlobalOptions } from "./types.ts";
async function pull(opts: GlobalOptions) {
@@ -13,7 +13,7 @@ async function pull(opts: GlobalOptions) {
return;
}
const userInfo = await requireLogin(opts);
await requireLogin(opts);
const list: {
id: number;
name: string;
@@ -26,12 +26,6 @@ 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 }[]) =>
@@ -62,10 +56,14 @@ async function pull(opts: GlobalOptions) {
const x of list
) {
console.log("syncing " + x.name);
const f = new ResourceTypeFile();
f.description = x.description;
f.schema = JSON.parse(x.schema);
await f.push(workspace.workspaceId, x.name);
await pushResourceTypeDef(
workspace.workspaceId,
x.name,
{
description: x.description,
schema: JSON.parse(x.schema),
},
);
}
}

View File

@@ -1,6 +1,5 @@
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;
@@ -48,18 +47,13 @@ export async function browserLogin(
}
const server = Deno.listen({ transport: "tcp", port });
const url = `${baseUrl}user/cli?port=${port}`
console.log(`Login by going to ${url}`);
try {
open(url)
console.log("Opened browser for you");
} catch { }
console.log(`Login by going to ${baseUrl}user/cli?port=${port}`);
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),
);

View File

@@ -8,17 +8,15 @@ 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 sync from "./sync.ts";
// import folder from "./folder.ts";
import { tryResolveVersion } from "./context.ts";
import { GlobalOptions } from "./types.ts";
const VERSION = "v1.69.0";
const VERSION = "v1.60.0";
let command: any = new Command()
const command = 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.",
@@ -34,9 +32,10 @@ let command: any = new Command()
.command("resource", resource)
.command("user", user)
.command("variable", variable)
.command("push", push)
.command("pull", pull)
.command("hub", hub)
.command("folder", folder)
.command("sync", sync)
// .command("folder", folder)
.command("version", "Show version information")
.action(async (opts) => {
console.log("CLI build against " + VERSION);
@@ -62,12 +61,6 @@ let command: any = new Command()
}),
);
if (Number.parseInt(VERSION.replace("v", "").replace(".", "")) > 1700) {
command = command
.command("push", push)
.command("pull", pull);
}
try {
await command.parse(Deno.args);
} catch (e) {

View File

@@ -1,45 +1,85 @@
// deno-lint-ignore-file no-explicit-any
import { resolveWorkspace } from "./context.ts";
import { GlobalOptions } from "./types.ts";
import { colors, Command, JSZip } from "./deps.ts";
import { Workspace } from "./workspace.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);
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 zipResponse = await fetch(
const tarResponse = await fetch(
workspace.remote + "api/w/" + workspace.workspaceId +
"/workspaces/tarball?archive_type=zip",
"/workspaces/tarball",
{
headers: requestHeaders,
method: "GET",
},
);
if (!zipResponse.ok) {
if (!tarResponse.ok) {
console.log(
colors.red(
"Failed to request tarball from API " + zipResponse.statusText,
"Failed to request tarball from API " + tarResponse.statusText,
),
);
throw new Error(await zipResponse.text());
console.log(await tarResponse.text());
return;
}
const blob = await zipResponse.blob();
return await JSZip.loadAsync(blob);
}
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 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."));
}
const command = new Command()
@@ -47,6 +87,6 @@ const command = new Command()
"Pull all definitions in the current workspace from the API and write them to disk.",
)
.arguments("<dir:string>")
.action(stub as any);
.action(pull as any);
export default command;

View File

@@ -1,21 +1,266 @@
// deno-lint-ignore-file no-explicit-any
import { colors, Command } from "./deps.ts";
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 { GlobalOptions } from "./types.ts";
import { pushVariable } from "./variable.ts";
import { pushResourceType } from "./resource-type.ts";
import { pushFolder } from "./folder.ts";
async function stub(
_opts: GlobalOptions,
_dir?: string,
) {
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);
console.log(
colors.red.underline(
'Push is deprecated. Use "sync push --raw" instead. See <TODO_LINK_HERE> for more information.',
colors.blue(
"Found " + (normal.length + resourceTypes.length + folders.length) +
" candidates",
),
);
for (const resourceType of resourceTypes) {
const fileName = resourceType.path.substring(
resourceType.path.lastIndexOf("/") + 1,
);
const fileNameParts = fileName.split(".");
// invalid file names, like my.cool.script.script.json. Not valid.
if (fileNameParts.length != 3) {
console.log(
colors.yellow("invalid file name found at " + resourceType.path),
);
continue;
}
// filter out non-json files. Note that we filter out script contents above, so this is really an error.
if (fileNameParts.at(-1) != "json") {
console.log(colors.yellow("non-JSON file found at " + resourceType.path));
continue;
}
console.log("pushing resource type " + fileNameParts.at(-3)!);
await 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(stub as any);
.action(push as any);
export default command;

View File

@@ -1,128 +1,65 @@
// deno-lint-ignore-file no-explicit-any
import {
Difference,
GlobalOptions,
PushDiffs,
Resource as ResourceI,
setValueByPath,
} from "./types.ts";
import { GlobalOptions } from "./types.ts";
import { requireLogin, resolveWorkspace } from "./context.ts";
import {
colors,
Command,
EditResourceType,
microdiff,
ResourceService,
ResourceType,
Table,
} from "./deps.ts";
import { Any, decoverto, model, property } from "./decoverto.ts";
import { colors, Command, ResourceService, Table } from "./deps.ts";
@model()
export class ResourceTypeFile implements ResourceI, PushDiffs {
@property(Any)
type ResourceTypeFile = {
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 = decoverto.type(ResourceTypeFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, name);
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,
},
});
}
}
type PushOptions = GlobalOptions;
@@ -137,7 +74,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 pushed"));
console.log(colors.bold.underline.green("Resource successfully pushed"));
}
async function list(opts: GlobalOptions) {

View File

@@ -1,141 +1,80 @@
import {
Difference,
GlobalOptions,
PushDiffs,
Resource as Resource2,
setValueByPath,
} from "./types.ts";
import { GlobalOptions } from "./types.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import {
colors,
Command,
EditResource,
microdiff,
Resource,
ResourceService,
Table,
} from "./deps.ts";
import { Any, decoverto, model, property } from "./decoverto.ts";
import { colors, Command, Resource, ResourceService, Table } from "./deps.ts";
@model()
export class ResourceFile implements Resource2, PushDiffs {
@property(Any)
value?: any;
@property(() => String)
type ResourceFile = {
value: any;
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 = decoverto.type(ResourceFile).rawToInstance(
await Deno.readTextFile(filePath),
);
await data.push(workspace, remotePath);
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,
},
});
}
}
type PushOptions = GlobalOptions;
@@ -155,7 +94,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 ${remotePath} pushed`));
console.log(colors.bold.underline.green("Resource successfully pushed"));
}
async function list(opts: GlobalOptions) {

View File

@@ -2,52 +2,20 @@
import { GlobalOptions } from "./types.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import {
colors,
Command,
JobService,
readAll,
Script,
ScriptService,
Table,
} from "./deps.ts";
import { Any, array, decoverto, model, property } from "./decoverto.ts";
} from "https://deno.land/x/windmill@v1.50.0/windmill-api/index.ts";
import { colors, Command, readAll, ScriptService, Table } from "./deps.ts";
@model()
export class ScriptFile {
@property(() => String)
type ScriptFile = {
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(
@@ -76,78 +44,7 @@ async function push(
await requireLogin(opts);
await pushScript(filePath, contentPath, workspace.workspaceId, remotePath);
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
console.log(colors.bold.underline.green("Script successfully pushed"));
}
export async function findContentFile(filePath: string) {
@@ -155,7 +52,6 @@ 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(
@@ -174,7 +70,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) {
@@ -183,35 +79,23 @@ 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 = decoverto.type(ScriptFile).rawToInstance(
await Deno.readTextFile(filePath),
);
const data: ScriptFile = JSON.parse(await Deno.readTextFile(filePath));
const content = await Deno.readTextFile(contentPath);
const language = inferContentTypeFromFilePath(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);
}
let parent_hash = data.parent_hash;
if (!parent_hash) {
try {
@@ -266,14 +150,16 @@ async function list(opts: GlobalOptions & { showArchived?: boolean }) {
}
new Table()
.header(["path", "summary", "language", "created by"])
.header(["path", "hash", "kind", "language", "created at", "created by"])
.padding(2)
.border(true)
.body(
total.map((x) => [
x.path,
x.summary,
x.hash,
x.kind,
x.language,
x.created_at,
x.created_by,
]),
)

View File

@@ -1,643 +0,0 @@
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,31 +11,14 @@ import {
Table,
WorkspaceService,
} from "./deps.ts";
import { decoverto, model, property } from "./decoverto.ts";
import { requireLogin } from "./context.ts";
@model()
export class Workspace {
@property(() => String)
export type Workspace = {
remote: string;
@property(() => String)
workspaceId: string;
@property(() => String)
name: string;
@property(() => String)
token: string;
constructor(
remote: string,
workspaceId: string,
name: string,
token: string,
) {
this.remote = remote;
this.workspaceId = workspaceId;
this.name = name;
this.token = token;
}
}
};
function makeWorkspaceStream(
readable: ReadableStream<Uint8Array>,
@@ -50,9 +33,7 @@ function makeWorkspaceStream(
if (line.length <= 2) {
return;
}
const workspace = decoverto.type(Workspace).rawToInstance(line);
workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases!
controller.enqueue(workspace);
controller.enqueue(JSON.parse(line) as Workspace);
} catch {
/* ignore */
}
@@ -61,17 +42,14 @@ function makeWorkspaceStream(
);
}
export async function getWorkspaceStream() {
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
write: false,
read: true,
});
return makeWorkspaceStream(file.readable);
}
export async function allWorkspaces(): Promise<Workspace[]> {
async function allWorkspaces(): Promise<Workspace[]> {
try {
const workspaceStream = await getWorkspaceStream();
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
write: false,
read: true,
});
const workspaceStream = makeWorkspaceStream(file.readable);
const workspaces: Workspace[] = [];
for await (const workspace of workspaceStream) {
workspaces.push(workspace);
@@ -109,7 +87,8 @@ export async function getActiveWorkspace(
export async function getWorkspaceByName(
workspaceName: string,
): Promise<Workspace | undefined> {
const workspaceStream = await getWorkspaceStream();
const file = await Deno.open((await getRootStore()) + "remotes.ndjson");
const workspaceStream = makeWorkspaceStream(file.readable);
for await (const workspace of workspaceStream) {
if (workspace.name === workspaceName) {
return workspace;
@@ -151,11 +130,7 @@ async function switchC(opts: GlobalOptions, workspaceName: string) {
const all = await allWorkspaces();
if (all.findIndex((x) => x.name === workspaceName) === -1) {
console.log(colors.red.bold(`! This workspace profile ${workspaceName} does not exist locally.`));
console.log("available workspace profiles:")
for (const w of all) {
console.log(' - ' + w.name)
}
console.log(colors.red.bold("! This workspace name does not exist."));
return;
}
@@ -188,8 +163,14 @@ export async function add(
workspaceName = await Input.prompt("Name this workspace:");
}
const all = await allWorkspaces();
if (all.findIndex((x) => x.name === workspaceName) !== -1) {
console.log(colors.red.bold("! Workspace name already exists"));
return;
}
if (!workspaceId) {
workspaceId = await Input.prompt({ message: "Enter the ID of this workspace", default: workspaceName, suggestions: [workspaceName] });
workspaceId = await Input.prompt("Enter the ID of this workspace");
}
if (!remote) {
@@ -201,40 +182,24 @@ export async function add(
remote = url.toString();
} catch {
// not a url
remote = new URL(
await Input.prompt({
message: "Enter the Remote URL",
suggestions: ["https://app.windmill.dev/"],
default: "https://app.windmill.dev/"
}),
).toString();
remote = new URL(await Input.prompt("Enter the Remote URL")).toString();
}
}
remote = new URL(remote).toString(); // add trailing slash in all cases!
let token = await tryGetLoginInfo(opts);
while (!token) {
token = await loginInteractive(remote);
}
setClient(
token,
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote,
);
let alreadyExists = false
try {
alreadyExists = await WorkspaceService.existsWorkspace({
requestBody: { id: workspaceId },
})
} catch (e) {
console.log(colors.red.bold("! Credentials or instance is invalid. Aborting."));
throw e
}
if (opts.create) {
setClient(token, remote.endsWith('/') ? remote.substring(0, remote.length - 1) : remote);
if (
!alreadyExists
!await WorkspaceService.existsWorkspace({
requestBody: { id: workspaceId },
})
) {
console.log(colors.yellow(`Workspace at id ${workspaceId} on ${remote} does not exist. Creating...`));
console.log(colors.yellow("Workspace does not exist. Creating..."));
await WorkspaceService.createWorkspace({
requestBody: {
id: workspaceId,
@@ -243,14 +208,6 @@ export async function add(
},
});
}
} else if (!alreadyExists) {
console.log(colors.red.bold(`! Workspace at id ${workspaceId} on ${remote} does not exist. Re-run with --create to create it. Aborting.`));
console.log("On that instance and with those credentials, the workspaces that you can access are:")
const workspaces = await WorkspaceService.listWorkspaces()
for (const workspace of workspaces) {
console.log(`- ${workspace.id} (name: ${workspace.name})`)
}
Deno.exit(1);
}
await addWorkspace({
@@ -258,40 +215,27 @@ export async function add(
remote: remote,
workspaceId: workspaceId,
token: token,
}, opts);
});
await Deno.writeTextFile(
(await getRootStore()) + "/activeWorkspace",
workspaceName,
);
console.log(colors.green.underline(`Added workspace ${workspaceName} for ${workspaceId} on ${remote}!`));
console.log(colors.green.underline("Succesfully added workspace!"));
}
export async function addWorkspace(workspace: Workspace, opts: any) {
workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases!
export async function addWorkspace(workspace: Workspace) {
const file = await Deno.open((await getRootStore()) + "remotes.ndjson", {
append: true,
write: true,
read: true,
read: false,
create: true,
});
await removeWorkspace(workspace.name, true, opts);
await file.write(new TextEncoder().encode(JSON.stringify(workspace) + "\n"));
file.close();
}
export async function removeWorkspace(name: string, silent: boolean, opts: any) {
async function remove(_opts: GlobalOptions, name: string) {
const orgWorkspaces = await allWorkspaces();
if (orgWorkspaces.findIndex((x) => x.name === name) === -1) {
if (!silent) {
console.log(colors.red.bold(`! Workspace profile ${name} does not exist locally`));
console.log("available workspace profiles:")
await list(opts)
}
return;
}
if (silent) {
console.log(colors.yellow(`Replacing existing workspace ${name}`))
}
await Deno.writeTextFile(
(await getRootStore()) + "remotes.ndjson",
orgWorkspaces
@@ -299,13 +243,7 @@ export async function removeWorkspace(name: string, silent: boolean, opts: any)
.map((x) => JSON.stringify(x))
.join("\n"),
);
if (!silent) {
console.log(colors.green.underline(`Succesfully removed workspace ${name}!`));
}
}
async function remove(_opts: GlobalOptions, name: string) {
await removeWorkspace(name, false, _opts);
console.log(colors.green.underline("Succesfully removed workspace!"));
}
const command = new Command()

View File

@@ -14,6 +14,7 @@ BigInt.prototype.toJSON = function () {
};
export { pgSql, pgClient } from './pg.ts'
export { mySql, mysqlClient } from './mysql.ts'
export type Sql = string
export type Email = string

View File

@@ -25,8 +25,9 @@ import { type Resource } from "./mod.ts"
export function pgClient(
db: Resource<"postgresql">
) {
const databaseUrl = 'postgresql://' + db.user + ':' + db.password + '@' + db.host + ':' + db.port + '/' + db.dbname + '?sslmode=' + db.sslmode
return new Client(databaseUrl)
db.database = db.dbname
db.hostname = db.host
return new Client(db)
}
/**

View File

@@ -20,41 +20,29 @@ services:
timeout: 5s
retries: 5
windmill_server:
windmill:
image: ghcr.io/windmill-labs/windmill:main
deploy:
replicas: 1
# Set privileged to true if enabling nsjail
privileged: false
restart: unless-stopped
ports:
- 8000:8000
environment:
- DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db/windmill?sslmode=disable
- BASE_URL=http://${WM_BASE_URL}
- BASE_INTERNAL_URL=http://localhost:8000
- RUST_LOG=info
## You can set the number of workers to > 0 and not need any separate worker service
- NUM_WORKERS=0
- DISABLE_SERVER=false
- METRICS_ADDR=false
depends_on:
db:
condition: service_healthy
windmill_worker:
image: ghcr.io/windmill-labs/windmill:main
deploy:
replicas: 3
restart: unless-stopped
environment:
- DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db/windmill?sslmode=disable
- BASE_URL=http://${WM_BASE_URL}
- BASE_INTERNAL_URL=http://windmill_server:8000
- RUST_LOG=info
- NUM_WORKERS=1
- DISABLE_SERVER=true
- NUM_WORKERS=3
- KEEP_JOB_DIR=false
- DENO_PATH=/usr/bin/deno
- PYTHON_PATH=/usr/local/bin/python3
- METRICS_ADDR=false
# for ease of use, nsjail which provide isolation in untrusted environment is disabled by default.
# To enable it, uncomment the following line, set the container as privileged and
# rebuild the image with nsjail=true or use the enterprise edition
# - DISABLE_NSJAIL=false
# - DISABLE_NUSER=false
# - NSJAIL_PATH=nsjail
depends_on:
db:
condition: service_healthy

View File

@@ -1,140 +0,0 @@
FROM debian:buster-slim as nsjail
WORKDIR /nsjail
ARG nsjail=""
RUN if [ "$nsjail" = "true" ]; then apt-get -y update \
&& apt-get install -y \
bison=2:3.3.* \
flex=2.6.* \
g++=4:8.3.* \
gcc=4:8.3.* \
git=1:2.20.* \
libprotobuf-dev=3.6.* \
libnl-route-3-dev=3.4.* \
make=4.2.* \
pkg-config=0.29-6 \
protobuf-compiler=3.6.*; fi
RUN if [ "$nsjail" = "true" ]; then git clone -b master --single-branch https://github.com/google/nsjail.git . \
&& git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800; fi
RUN if [ "$nsjail" = "true" ]; then make; else touch nsjail; fi
FROM rust:slim-buster AS rust_base
RUN apt-get update && apt-get install -y git libssl-dev pkg-config npm
RUN apt-get -y update \
&& apt-get install -y \
curl lld nodejs npm
RUN rustup component add rustfmt
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install cargo-chef
WORKDIR /windmill
ENV SQLX_OFFLINE=true
ENV CARGO_INCREMENTAL=1
FROM node:19-alpine as frontend
# install dependencies
WORKDIR /frontend
COPY ./frontend/package.json ./frontend/package-lock.json ./
RUN npm ci
# Copy all local files into the image.
COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
RUN cd /backend/windmill-api && . ./build_openapi.sh
RUN npm run generate-backend-client
ENV NODE_OPTIONS "--max-old-space-size=8192"
RUN npm run build
RUN npm run check
FROM rust_base AS planner
COPY ./openflow.openapi.yaml /openflow.openapi.yaml
COPY ./backend ./
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo chef prepare --recipe-path recipe.json
FROM rust_base AS builder
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
COPY ./openflow.openapi.yaml /openflow.openapi.yaml
COPY ./backend ./
COPY --from=frontend /frontend /frontend
COPY --from=frontend /backend/windmill-api/openapi-deref.yaml ./windmill-api/openapi-deref.yaml
COPY .git/ .git/
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features"
FROM python:3.10.8-slim-buster
ARG APP=/usr/src/app
RUN apt-get update \
&& apt-get install -y ca-certificates wget curl git jq libprotobuf-dev libnl-route-3-dev unzip \
&& apt-get install -y ca-certificates wget curl git jq libprotobuf-dev libnl-route-3-dev unzip build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
curl -o rclone.zip "https://downloads.rclone.org/v1.60.1/rclone-v1.60.1-linux-$arch.zip"; \
unzip -p rclone.zip rclone-v1.60.1-linux-$arch/rclone > /usr/bin/rclone; rm rclone.zip; \
chown root:root /usr/bin/rclone; chmod 755 /usr/bin/rclone
RUN set -eux; \
arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
url=; \
case "$arch" in \
'amd64') \
targz='go1.19.3.linux-amd64.tar.gz'; \
;; \
'arm64') \
targz='go1.19.3.linux-arm64.tar.gz'; \
;; \
'armhf') \
targz='go1.19.3.linux-armv6l.tar.gz'; \
;; \
*) echo >&2 "error: unsupported architecture '$arch' (likely packaging update needed)"; exit 1 ;; \
esac; \
wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz";
ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
ENV TZ=Etc/UTC
RUN /usr/local/bin/python3 -m pip install pip-tools
COPY --from=builder /windmill/target/release/windmill ${APP}/windmill
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
COPY --from=denoland/deno:latest /usr/bin/deno /usr/bin/deno
RUN /usr/local/bin/python3 -m pip install openbb[all]
RUN mkdir -p ${APP}
WORKDIR ${APP}
EXPOSE 8000
CMD ["./windmill"]

File diff suppressed because it is too large Load Diff

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