chore: eetest (#2937)

* chore: Windmill integration tests

* Revert github workflow temp changes

* fix ee

---------

Co-authored-by: gbouv <guillaume@windmill.dev>
This commit is contained in:
Ruben Fiszel
2023-12-30 11:29:00 +09:00
committed by GitHub
parent af59b5cf54
commit 19246bcf3e
18 changed files with 1106 additions and 18 deletions

25
.github/change-versions-mac.sh vendored Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
VERSION=$1
echo "Updating versions to: $VERSION"
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/main.ts
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i '' -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
# sed -i '' -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
sed -i '' -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i '' -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i '' -E "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
cd ${root_dirpath}/frontend && npm i --package-lock-only

View File

@@ -1,23 +1,25 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
VERSION=$1
echo "Updating versions to: $VERSION"
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" backend/Cargo.toml
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" cli/main.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" benchmarks/lib.ts
sed -i -e "/version: /s/: .*/: $VERSION/" backend/windmill-api/openapi.yaml
sed -i -e "/version: /s/: .*/: $VERSION/" openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" frontend/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill/pyproject.toml
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill/pyproject.toml
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill_pg/pyproject.toml
# sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" lsp/Pipfile
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" lsp/Pipfile
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/main.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
# sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" backend/Cargo.lock
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
cd frontend && npm i --package-lock-only
cd ${root_dirpath}/frontend && npm i --package-lock-only

View File

@@ -43,6 +43,8 @@ jobs:
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -56,7 +58,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
${{ steps.meta-public.outputs.tags }}
labels: |
${{ steps.meta-public.outputs.labels }}
@@ -79,6 +81,8 @@ jobs:
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -102,7 +106,7 @@ jobs:
features=enterprise,enterprise_saml
nsjail=true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
${{ steps.meta-ee-public.outputs.tags }}
labels: |
${{ steps.meta-ee-public.outputs.labels }}
@@ -322,3 +326,53 @@ jobs:
folder: ${{ steps.extract.outputs.destination }}
bucket: windmill-frontend
bucket-region: us-east-1
run_integration_test:
runs-on: ubicloud
needs: [build_ee]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Prepare test run
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
run: cd integration_tests && ./build.sh
- name: Test run
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
timeout-minutes: 15
env:
LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }}
run: cd integration_tests && ./run.sh
- name: Archive logs
uses: actions/upload-artifact@v3
if: always()
with:
name: Windmill Integration Tests Logs
path: |
integration_tests/logs
tag_latest:
runs-on: ubicloud
needs: [run_integration_test]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull dev
run:
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker push -a ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee

4
integration_tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
!.env
*/__pycache__/
.venv/
logs/

View File

@@ -0,0 +1,45 @@
Windmill integration tests
==========================
Integration that are run on every push to the main branch (excluding tags)
The concept is the following:
1. It pulls the latest published image with the version from (version.txt)[../version.txt] and deploys a Windmill stack using the (docker-compose.yml)[docker-compose.yml]
2. It runs the tests using python testing framework (`python -m unittest -v test`). All test classes exported in (__init__.py)[./test/__init__py] will be run
3. Then it upgrades the stack to the latest (unpublished) version of Windmill built for this commit. It expects a docker image with the tag `dev` to be present.
4. It re-runs the tests with the following env var: `WMILL_RUNNING_DEV` set to `1`
Some tests behaves differently depending on `WMILL_RUNNING_DEV`. This way we can test that scripts/flows/schedules... deployed on a previous version of Windmill won't break with the upgrade
Some tests are skipped unless `WMILL_RUNNING_DEV == 1`, those are just regular integration tests that just needs to be run on the dev version.
#### Running locally
Running the tests locally is not easy given that we need to have a Docker image running the latest version of the code. However, the tests simply reaches Windmill API on `http://localhost:8000`, so it can easily be modified to run alongside a simple `cargo run --features enterprise`.
Note that Windmill Enterprise version is required, you'll need a license key for all the tests to run. It needs to be set to the environment variable: `WM_LICENSE_KEY_CI`
For example, to run `identity_script_test.py`, you can do the following:
In one terminal, run your local version of Windmill:
```bash
cargo run --features enterprise
```
Then in another tab, run the test:
```bash
python -m unittest -v test.TestIdentityScript
```
Some tests requires additional setup. This is the case of the SDK tests, which requires Windmill SDK package to be published to either private NPM registry or PyPI server. Custom setup logic can be found in (build.sh)[./build.sh].
#### TODOs
Add integration tests for:
- [ ] Test Python SDK
- [ ] Test job failures as well
- [ ] Test dedicated workers
- [ ] Error handlers and Recovery handlers for schedule
- [ ] Concurrency limits

30
integration_tests/build.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
source ${script_dirpath}/common.sh
docker pull ${WM_IMAGE}:${WM_VERSION}
docker pull ${WM_IMAGE}:${WM_VERSION_DEV}
previous_version=${WM_VERSION}
custom_version="${WM_VERSION}-dev"
echo "Previous version was: ${previous_version}"
${root_dirpath}/.github/change-versions.sh ${custom_version}
# Publishing Windmill SDK package to private NPM registry
npm install -g npm-cli-login
docker compose up npm_registry -d
sleep 2
curl -vvv -XPUT -H "Content-type: application/json" -d '{ "name": "windmill", "password": "changeme" }' 'http://localhost:4873/-/user/org.couchdb.user:windmill' | jq -r .token
npm-cli-login -u windmill -p changeme -e admin@windmill.dev -r http://localhost:4873
cd ${root_dirpath}/typescript-client
${root_dirpath}/typescript-client/publish.sh '--registry http://localhost:4873'
cd ${script_dirpath}
docker compose down
# TODO: publish Python SDK to private PyPI registry
${root_dirpath}/.github/change-versions.sh ${previous_version}

10
integration_tests/common.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
export WM_VERSION=$(cat ${root_dirpath}/version.txt)
# WM_VERSION="1.228.1"
export WM_VERSION_DEV="dev"
# WM_VERSION_DEV="1.229.0"
export WM_IMAGE="ghcr.io/windmill-labs/windmill-ee"

View File

@@ -0,0 +1,108 @@
version: "3.7"
services:
db:
deploy:
# To use an external database, set replicas to 0 and set DATABASE_URL to the external database url in the .env file
replicas: 1
image: postgres:14
restart: unless-stopped
volumes:
- db_data:/var/lib/postgresql/data
expose:
- 5432
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
windmill_server:
image: ${WM_IMAGE}:${WM_VERSION}
labels:
dev.windmill.role: server
deploy:
replicas: 1
restart: unless-stopped
expose:
- 8000
ports:
- 8000:8000
environment:
- DATABASE_URL=postgres://postgres:changeme@db/windmill?sslmode=disable
- MODE=server
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/version || exit 1"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
db:
condition: service_healthy
windmill_worker:
image: ${WM_IMAGE}:${WM_VERSION}
labels:
dev.windmill.role: worker
deploy:
replicas: 1
resources:
limits:
cpus: "1"
memory: 2048M
restart: unless-stopped
environment:
- DATABASE_URL=postgres://postgres:changeme@db/windmill?sslmode=disable
- MODE=worker
- WORKER_GROUP=default
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/version || exit 1"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
db:
condition: service_healthy
windmill_server:
condition: service_healthy
# to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill
volumes:
# mount the docker socket to allow to run docker containers from within the workers
- /var/run/docker.sock:/var/run/docker.sock
- worker_dependency_cache:/tmp/windmill/cache
npm_registry:
image: verdaccio/verdaccio
environment:
- VERDACCIO_PROTOCOL=http
- VERDACCIO_PUBLIC_URL=http://npm_registry:4873
ports:
- 4873:4873
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:4873/-/ping || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- ./verdaccio:/verdaccio/conf
- npm_registry_data:/verdaccio/storage
pypi_server:
image: pypiserver/pypiserver:latest
platform: linux/x86_64
ports:
- 8080:8080
volumes:
- pypi_data:/data/packages
command: run -P . -a . /data/packages
volumes:
db_data: null
npm_registry_data: null
worker_dependency_cache: null
pypi_data: null

View File

@@ -0,0 +1,7 @@
anyio==4.2.0
certifi==2023.11.17
h11==0.14.0
httpcore==1.0.2
httpx==0.26.0
idna==3.6
sniffio==1.3.0

24
integration_tests/run.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
source ${script_dirpath}/common.sh
echo "Setting up python environment..."
python --version
python -m venv ${script_dirpath}/.venv/
${script_dirpath}/.venv/bin/pip install -r ${script_dirpath}/requirements.txt
echo "Running initial round of test for version ${WM_VERSION}"
WM_IMAGE=${WM_IMAGE} WM_VERSION=${WM_VERSION} docker compose up -d
mkdir -p ./logs
echo "" > ./logs/docker-compose.log
docker compose logs --no-color --follow &> ./logs/docker-compose.log &
cd ${script_dirpath} && ${script_dirpath}/.venv/bin/python -m unittest -v test
echo "Running second round of test for version ${WM_VERSION_DEV}"
WM_IMAGE=${WM_IMAGE} WM_VERSION=${WM_VERSION_DEV} docker compose up -d
WM_VERSION_DEV=${WM_VERSION}-dev WMILL_RUNNING_DEV=1 ${script_dirpath}/.venv/bin/python -m unittest -v test

View File

@@ -0,0 +1,4 @@
from .identity_script_test import *
from .increment_flow_test import *
from .schedule_test import *
from .windmill_sdk_test import *

View File

@@ -0,0 +1,81 @@
import os
import time
import unittest
from .wmill_integration_test_utils import WindmillClient
PATH_TEMPLATE = "u/admin/{lang}_identity_script"
SCRIPTS = {
"bash": """
x="$1"
echo ${x}
""",
"bun": """
export async function main(x: number) {
return x;
}
""",
"deno": """
export async function main(x: number) {
return x;
}
""",
"go": """
package inner
func main(x int) (interface{}, error) {
return x, nil
}
""",
"python3": """
def main(x: int):
return x
""",
}
class TestIdentityScript(unittest.TestCase):
_client: WindmillClient
@classmethod
def setUpClass(cls) -> None:
print("Running {}".format(cls.__name__))
cls._client = WindmillClient()
if not os.environ.get("WMILL_RUNNING_DEV", False):
for lang, script in SCRIPTS.items():
cls._client.create_script(
path=PATH_TEMPLATE.format(lang=lang),
content=script,
language=lang,
)
@classmethod
def tearDownClass(cls) -> None:
if os.environ.get("WMILL_RUNNING_DEV", False):
for lang in SCRIPTS:
cls._client.delete_script(path=PATH_TEMPLATE.format(lang=lang))
def test_bash(self):
path = PATH_TEMPLATE.format(lang="bash")
result = self._client.run_sync(path, {"x": 5})
self.assertEqual(result, "5") # bash only knows strings
def test_bun(self):
path = PATH_TEMPLATE.format(lang="bun")
result = self._client.run_sync(path, {"x": 5})
self.assertEqual(result, 5)
def test_deno(self):
path = PATH_TEMPLATE.format(lang="deno")
result = self._client.run_sync(path, {"x": 5})
self.assertEqual(result, 5)
def test_go(self):
path = PATH_TEMPLATE.format(lang="go")
result = self._client.run_sync(path, {"x": 5})
self.assertEqual(result, 5)
def test_python(self):
path = PATH_TEMPLATE.format(lang="python3")
result = self._client.run_sync(path, {"x": 5})
self.assertEqual(result, 5)

View File

@@ -0,0 +1,156 @@
import json
import os
import time
import unittest
from .wmill_integration_test_utils import WindmillClient
PATH = "u/admin/increment_flow"
FLOW_VALUE = """
{
"summary": "",
"schema":
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties":
{
"x":
{
"type": "integer",
"description": "",
"format": ""
}
},
"required":
[],
"type": "object",
"order":
[
"x"
]
},
"value":
{
"modules":
[
{
"id": "a",
"value":
{
"tag": "",
"lock": "{\\n \\"version\\": \\"3\\",\\n \\"remote\\": {}\\n}\\n",
"type": "rawscript",
"content": "export async function main(x: number) {\\n return x + 1\\n}\\n",
"language": "deno",
"input_transforms":
{
"x":
{
"expr": "flow_input.x + 5",
"type": "javascript"
}
}
}
},
{
"id": "b",
"value":
{
"lock": "{\\n \\"dependencies\\": {}\\n}\\n//bun.lockb\\n<empty>",
"type": "rawscript",
"content": "export async function main(x: number) {\\n return x + 1\\n}\\n",
"language": "bun",
"input_transforms":
{
"x":
{
"expr": "results.a",
"type": "javascript"
}
}
}
},
{
"id": "c",
"value":
{
"lock": "",
"type": "rawscript",
"content": "def main(x: int):\\n return x + 1",
"language": "python3",
"input_transforms":
{
"x":
{
"expr": "results.b",
"type": "javascript"
}
}
}
},
{
"id": "d",
"value":
{
"lock": "module mymod\\n\\ngo 1.21.5\\n//go.sum\\n",
"type": "rawscript",
"content": "package inner\\nfunc main(x int) (interface{}, error) {\\n\\treturn x + 1, nil\\n}\\n",
"language": "go",
"input_transforms":
{
"x":
{
"expr": "results.c",
"type": "javascript"
}
}
}
},
{
"id": "e",
"value":
{
"lock": "",
"type": "rawscript",
"content": "x=\\"$1\\"\\necho $((x+1))",
"language": "bash",
"input_transforms":
{
"x":
{
"expr": "`${results.d}`",
"type": "javascript"
}
}
}
}
]
},
"extra_perms":
{},
"ws_error_handler_muted": false
}
"""
class TestIncrementFlow(unittest.TestCase):
_client: WindmillClient
@classmethod
def setUpClass(cls) -> None:
print("Running {}".format(cls.__name__))
cls._client = WindmillClient()
if not os.environ.get("WMILL_RUNNING_DEV", False):
cls._client.create_flow(
path=PATH,
flow_value_json=FLOW_VALUE,
)
@classmethod
def tearDownClass(cls) -> None:
if os.environ.get("WMILL_RUNNING_DEV", False):
cls._client.delete_flow(path=PATH)
def test_flow(self):
result = self._client.run_sync(PATH, {"x": 5}, type="f")
self.assertEqual(result, "15") # bash only knows strings

View File

@@ -0,0 +1,136 @@
import datetime
import os
import time
import unittest
from .wmill_integration_test_utils import WindmillClient
FLOW_SCHEDULE_PATH = "u/admin/flow_schedule"
FLOW_PATH = "u/admin/scheduled_flow"
FLOW_VALUE = """
{
"summary": "",
"value":
{
"modules":
[
{
"id": "a",
"value":
{
"type": "rawscript",
"content": "def main(x: int):\\n return x",
"language": "python3",
"input_transforms":
{
"x":
{
"type": "javascript",
"expr": "flow_input.x"
}
},
"tag": ""
}
}
]
},
"edited_by": "",
"edited_at": "",
"archived": false,
"extra_perms":
{},
"schema":
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties":
{
"x":
{
"type": "integer",
"description": "",
"format": ""
}
},
"required":
[],
"type": "object",
"order":
[
"x"
]
}
}
"""
SCRIPT_SCHEDULE_PATH = "u/admin/script_schedule"
SCRIPT_PATH = "u/admin/scheduled_script"
SCRIPT_VALUE = """
def main(x: int):
return x
"""
class TestSchedule(unittest.TestCase):
_client: WindmillClient
@classmethod
def setUpClass(cls) -> None:
print("Running {}".format(cls.__name__))
cls._client = WindmillClient()
if not os.environ.get("WMILL_RUNNING_DEV", False):
cls._client.create_flow(
path=FLOW_PATH,
flow_value_json=FLOW_VALUE,
)
cls._client.create_script(
path=SCRIPT_PATH,
content=SCRIPT_VALUE,
language="python3",
)
cls._client.create_schedule(
path=FLOW_SCHEDULE_PATH,
runnable_path=FLOW_PATH,
type="flow",
args={"x": 5},
)
cls._client.create_schedule(
path=SCRIPT_SCHEDULE_PATH,
runnable_path=SCRIPT_PATH,
args={"x": 8},
)
@classmethod
def tearDownClass(cls) -> None:
if os.environ.get("WMILL_RUNNING_DEV", False):
cls._client.delete_schedule(path=FLOW_SCHEDULE_PATH)
cls._client.delete_schedule(path=SCRIPT_SCHEDULE_PATH)
cls._client.delete_flow(path=FLOW_PATH)
cls._client.delete_script(path=SCRIPT_PATH)
@staticmethod
def parse_db_datetime(db_datetime: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(db_datetime.strip("Z") + "+00:00")
def test_script_schedule_running(self):
# the script is scheduled to run every 5 seconds, wait for 6 before checking is has run
time.sleep(6)
script_runs = self._client.get_latest_job_runs(path=SCRIPT_PATH)
if len(script_runs) == 0:
self.fail("No script runs found")
latest_run = script_runs[0]
latest_run_time = TestSchedule.parse_db_datetime(latest_run["created_at"])
time_now = datetime.datetime.now(datetime.timezone.utc)
# check that last run is within 5 seconds of now
self.assertTrue(time_now - latest_run_time < datetime.timedelta(seconds=5))
def test_flow_schedule_running(self):
# the flow is scheduled to run every 5 seconds, wait for 6 before checking is has run
time.sleep(6)
flow_runs = self._client.get_latest_job_runs(path=FLOW_PATH)
if len(flow_runs) == 0:
self.fail("No flow runs found")
latest_run = flow_runs[0]
latest_run_time = TestSchedule.parse_db_datetime(latest_run["created_at"])
time_now = datetime.datetime.now(datetime.timezone.utc)
# check that last run is within 5 seconds of now
self.assertTrue(time_now - latest_run_time < datetime.timedelta(seconds=5))

View File

@@ -0,0 +1,127 @@
import datetime
import os
import time
import unittest
from .wmill_integration_test_utils import WindmillClient
VARIABLE_PATH = "u/admin/test_variable"
VARIABLE_VALUE = "Hello world!"
DENO_SCRIPT_PATH = "u/admin/deno_sdk_test_script"
DENO_SCRIPT_VALUE = """
import * as wmill from "npm:windmill-client@{version}"
export async function main() {{
const val = await wmill.getVariable('u/admin/test_variable')
return val;
}}
"""
BUN_SCRIPT_PATH = "u/admin/bun_sdk_test_script"
BUN_SCRIPT_VALUE = """
import * as wmill from "windmill-client@{version}"
export async function main() {{
const val = await wmill.getVariable('u/admin/test_variable')
return val;
}}
"""
PYTHON_SCRIPT_PATH = "u/admin/python_sdk_test_script"
PYTHON_SCRIPT_VALUE = """
# requirements:
# wmill=={version}
import wmill
def main():
val = wmill.get_variable("u/admin/test_variable")
return val
"""
BASH_SCRIPT_PATH = "u/admin/bash_sdk_test_script"
BASH_SCRIPT_VALUE = """
val=$(curl -s -H "Authorization: Bearer $WM_TOKEN" \
"$BASE_INTERNAL_URL/api/w/$WM_WORKSPACE/variables/get_value/u/admin/test_variable" | jq -r .)
echo "$val"
"""
class TestWindmillSdk(unittest.TestCase):
_dev_version = os.environ.get("WM_VERSION_DEV", "0.0.0").strip()
_running_latest = False
_client: WindmillClient
@classmethod
def setUpClass(cls) -> None:
print("Running {}".format(cls.__name__))
cls._running_latest = os.environ.get("WMILL_RUNNING_DEV", "0") == "1"
cls._client = WindmillClient()
if cls._running_latest:
cls._client.set_npm_config_registry("http://npm_registry:4873")
cls._client.create_variable(
path=VARIABLE_PATH,
value=VARIABLE_VALUE,
)
cls._client.create_script(
path=DENO_SCRIPT_PATH,
content=DENO_SCRIPT_VALUE.format(version=cls._dev_version),
language="deno",
)
# TODO: See skipped annotations below
# cls._client.create_script(
# path=BUN_SCRIPT_PATH,
# content=BUN_SCRIPT_VALUE.format(version=cls._dev_version),
# language="bun",
# )
# cls._client.create_script(
# path=PYTHON_SCRIPT_PATH,
# content=PYTHON_SCRIPT_VALUE.format(version=cls._dev_version),
# language="python3",
# )
cls._client.create_script(
path=BASH_SCRIPT_PATH,
content=BASH_SCRIPT_VALUE,
language="bash",
)
@classmethod
def tearDownClass(cls) -> None:
if cls._running_latest:
cls._client.set_npm_config_registry("")
cls._client.delete_script(path=DENO_SCRIPT_PATH)
# TODO: See skipped annotations below
# cls._client.delete_script(path=BUN_SCRIPT_PATH)
# cls._client.delete_script(path=PYTHON_SCRIPT_PATH)
cls._client.delete_script(path=BASH_SCRIPT_PATH)
cls._client.delete_variable(path=VARIABLE_PATH)
cls._client.set_npm_config_registry("")
@unittest.skipUnless(
os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only"
)
def test_deno_sdk_usable(self):
result = self._client.run_sync(DENO_SCRIPT_PATH, {})
self.assertEqual(result, VARIABLE_VALUE)
@unittest.skipUnless(
# os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only"
False,
"TODO: Skipped for now b/c verdaccio doesn't support trailing slash at the end of URL",
)
def test_bun_sdk_usable(self):
result = self._client.run_sync(BUN_SCRIPT_PATH, {})
self.assertEqual(result, VARIABLE_VALUE)
@unittest.skipUnless(
# os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only"
False,
"TODO: Need to publish python SDK to private PiPY server",
)
def test_python_sdk_usable(self):
result = self._client.run_sync(PYTHON_SCRIPT_PATH, {})
self.assertEqual(result, VARIABLE_VALUE)
@unittest.skipUnless(
os.environ.get("WMILL_RUNNING_DEV", "0") == "1", "Runs on dev version only"
)
def test_bash_sdk_usable(self):
result = self._client.run_sync(BASH_SCRIPT_PATH, {})
self.assertEqual(result, VARIABLE_VALUE)

View File

@@ -0,0 +1,256 @@
import datetime
import time
import httpx
import json
import os
USERNAME = "admin"
class WindmillClient:
_url: str
_token: str
_workspace: str
_client: httpx.Client
def __init__(self):
self._workspace = "integration-tests"
self._url = "http://localhost:8000"
self._token = self._login()
self._client = self._init_client()
print("New client for Windmill version {}".format(self.get_version()))
self._create_workspace()
self._set_license_key()
def __del__(self):
self._logout()
self._client.close()
def _login(self) -> str:
with httpx.Client(base_url=self._url) as unauth_client:
response = unauth_client.post(
"/api/auth/login",
json={
"email": "admin@windmill.dev",
"password": "changeme",
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def _logout(self) -> None:
response = self._client.post(
"/api/auth/logout",
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
def _init_client(self):
token = self._token
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
}
return httpx.Client(
base_url=self._url,
headers=headers,
)
def _set_license_key(self):
license_key = os.environ.get("LICENSE_KEY", "").strip()
print(
"Setting license key to {}...{}".format(license_key[:15], license_key[-15:])
)
response = self._client.post(
"/api/settings/global/license_key",
json={
"value": license_key,
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
def _create_workspace(self):
print("Creating workspace for integration tests")
exists = self._client.post(
"/api/workspaces/exists",
json={
"id": self._workspace,
},
)
if exists.status_code // 100 == 2 and exists.content.decode() == "true":
print("Workspace already exists, not creating it")
return
response = self._client.post(
"/api/workspaces/create",
json={
"id": self._workspace,
"name": self._workspace,
"username": "admin",
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def set_npm_config_registry(self, registry_url: str):
response = self._client.post(
"/api/settings/global/npm_config_registry",
json={
"value": registry_url,
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
def run_sync(self, path: str, args: dict, type: str = "p"):
print(f"Running {path} with args {args}")
response = self._client.post(
f"/api/w/{self._workspace}/jobs/run_wait_result/{type}/{path}",
json=args,
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.json()
def create_script(self, path: str, content: str, language: str):
print(f"Creating script {path}")
response = self._client.post(
f"/api/w/{self._workspace}/scripts/create",
json={
"path": path,
"content": content,
"description": "",
"summary": "",
"language": language,
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
script_hash = response.content.decode()
print(f"Script hash for path {path} is {script_hash}")
time_now = datetime.datetime.now(datetime.timezone.utc)
while datetime.datetime.now(
datetime.timezone.utc
) - time_now < datetime.timedelta(seconds=60):
response = self._client.get(
f"/api/w/{self._workspace}/scripts/deployment_status/h/{script_hash}"
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
elif response.json()["lock"] != None:
# deployment successful -> return
return
elif response.json()["lock_error_logs"] != None:
raise Exception(response.json()["lock_error_logs"])
print(f"Waiting for script {path} with hash {script_hash} to be deployed")
time.sleep(1)
raise Exception(f"Script deployment failed for {path}")
def delete_script(self, path: str):
print(f"Deleting script {path}")
response = self._client.post(
f"/api/w/{self._workspace}/scripts/delete/p/{path}",
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def create_flow(self, path: str, flow_value_json: str):
print(f"Creating flow {path}")
parsed_flow = json.loads(flow_value_json)
if "path" not in parsed_flow:
parsed_flow["path"] = path
response = self._client.post(
f"/api/w/{self._workspace}/flows/create",
json=parsed_flow,
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def delete_flow(self, path: str):
print(f"Deleting flow {path}")
response = self._client.delete(
f"/api/w/{self._workspace}/flows/delete/{path}",
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def create_schedule(
self,
path: str,
runnable_path: str,
type: str = "script",
schedule: str = "*/5 * * * * *",
args: dict = {},
):
print(f"Creating schedule {path}")
response = self._client.post(
f"/api/w/{self._workspace}/schedules/create",
json={
"path": path,
"schedule": schedule,
"timezone": "Europe/Paris",
"script_path": runnable_path,
"is_flow": type == "flow",
"args": args,
"enabled": True,
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def delete_schedule(self, path: str):
print(f"Deleting schedule {path}")
response = self._client.delete(
f"/api/w/{self._workspace}/schedules/delete/{path}",
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def create_variable(
self,
path: str,
value: str,
):
print(f"Creating variable {path} with value '{value}'")
response = self._client.post(
f"/api/w/{self._workspace}/variables/create",
json={
"path": path,
"value": value,
"description": "",
"is_secret": False,
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def delete_variable(self, path: str):
print(f"Deleting variable {path}")
response = self._client.delete(
f"/api/w/{self._workspace}/variables/delete/{path}",
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.content.decode()
def get_latest_job_runs(self, path: str):
response = self._client.get(
f"/api/w/{self._workspace}/jobs/list?script_path_exact={path}"
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
return response.json()
def get_version(self):
response = self._client.get("/api/version")
return response.content.decode()

View File

@@ -0,0 +1,17 @@
storage: /verdaccio/storage/data
plugins: /verdaccio/plugins
auth:
htpasswd:
file: /verdaccio/storage/htpasswd
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
"**":
access: $all
publish: $authenticated
proxy: npmjs
listen:
- http://localhost:4873
- http://npm_registry:4873
log: { type: stdout, format: pretty, level: http }

View File

@@ -2,6 +2,8 @@
set -eou pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
args=${1:-}
rm -rf "${script_dirpath}/dist"
${script_dirpath}/build.sh
@@ -10,4 +12,4 @@ rm "${script_dirpath}/s3Types.ts"
tsc
cp "${script_dirpath}/src/client.ts" ${script_dirpath}
cp "${script_dirpath}/src/s3Types.ts" ${script_dirpath}
npm publish
npm publish ${args}