nixos/windmill: Add module test
This commit is contained in:
committed by
Bert Proesmans
parent
02fb5aea55
commit
df1c0930f3
@@ -1642,6 +1642,10 @@ in
|
||||
whoami = runTest ./whoami.nix;
|
||||
whoogle-search = runTest ./whoogle-search.nix;
|
||||
wiki-js = runTest ./wiki-js.nix;
|
||||
windmill = import ./windmill {
|
||||
inherit pkgs runTest;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
wine = handleTest ./wine.nix { };
|
||||
wireguard = import ./wireguard {
|
||||
inherit pkgs runTest;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{ lib, pkgs, ... }:
|
||||
{
|
||||
name = "windmill-api";
|
||||
|
||||
nodes = {
|
||||
windmill =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
virtualisation.memorySize = 2048; # 2GB
|
||||
networking.firewall.allowedTCPPorts = [ 8001 ];
|
||||
services.windmill = {
|
||||
enable = true;
|
||||
|
||||
serverPort = 8001;
|
||||
database.createLocally = true;
|
||||
};
|
||||
|
||||
systemd.services = {
|
||||
windmill-worker = {
|
||||
# "dotnet restore" (csharp executor) attempts to download the package index from api.nuget.org by default.
|
||||
# Using the following per-job nuget.config file will erase all package source references to prevent downloading.
|
||||
environment.FORCE_NUGET_CONFIG = ''
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
(pkgs.writers.writePython3Bin "integration-test" {
|
||||
flakeIgnore = [
|
||||
"E265" # block comment should start with '# '
|
||||
"E501" # line too long (NN > 79 characters)
|
||||
];
|
||||
} (builtins.readFile ./api-integration.py))
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
# windmill.forward_port(8001, 8001) # DEBUG interactive
|
||||
|
||||
with subtest("Server smoketest"):
|
||||
windmill.wait_for_unit("windmill.target")
|
||||
# ERROR; Do not early timeout because windmill starts running migration scripts.
|
||||
# There is no communication to systemd that signals migrations have finished.
|
||||
windmill.wait_for_open_port(8001)
|
||||
# NOTE; Wait a couple of seconds for all windmill components to finalise their database migration flow. This prevents race conditions on schema constraints.
|
||||
time.sleep(10) # seconds
|
||||
windmill.succeed("curl --silent --fail http://windmill:8001")
|
||||
t.assertIn("v${pkgs.windmill.version}", machine.succeed("curl --silent --fail http://windmill:8001/api/version"), "Mismatched version response")
|
||||
|
||||
with subtest("Validation"):
|
||||
windmill.succeed("integration-test --language python3 --script ${./python3.script} --input ${./python3.input}")
|
||||
windmill.succeed("integration-test --language go --script ${./go.script} --input ${./go.input}")
|
||||
windmill.succeed("integration-test --language bun --script ${./bun.script} --input ${./bun.input}")
|
||||
windmill.succeed("integration-test --language deno --script ${./deno.script} --input ${./deno.input}")
|
||||
windmill.succeed("integration-test --language php --script ${./php.script} --input ${./php.input}")
|
||||
windmill.succeed("integration-test --language bash --script ${./bash.script} --input ${./bash.input}")
|
||||
windmill.succeed("integration-test --language powershell --script ${./powershell.script} --input ${./powershell.input}")
|
||||
|
||||
# ERROR; "dotnet restore" requires write-access to <some-path(:home?)>/.dotnet
|
||||
# -- System.IO.IOException: Read-only file system : '/.dotnet'
|
||||
#
|
||||
# ERROR; "dotnet publish" requires internet connectivity to fetch "compilation workload" dependencies
|
||||
# -- error NU1100: Unable to resolve 'Microsoft.NET.ILLink.Tasks (>= 9.0.10)' for 'net9.0'.
|
||||
#
|
||||
# DISABLED windmill.succeed("integration-test --language csharp --script ${./csharp.script} --input ${./csharp.input}")
|
||||
'';
|
||||
}
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env nix
|
||||
#!nix shell nixpkgs#python3 --command python
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import pathlib
|
||||
from urllib.request import build_opener, HTTPCookieProcessor, Request
|
||||
from http.cookiejar import CookieJar
|
||||
import json
|
||||
import time
|
||||
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("-l", "--language", dest="language", type=str,
|
||||
help="Name of the scripting language", metavar="LANG", required=True)
|
||||
parser.add_argument("-s", "--script", dest="content_file", type=pathlib.Path,
|
||||
help="read script contents from FILE", metavar="FILE", required=True)
|
||||
parser.add_argument("-i", "--input", dest="input_file", type=pathlib.Path,
|
||||
help="read script arguments from FILE", metavar="FILE", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
cookiejar = CookieJar()
|
||||
cookieprocessor = HTTPCookieProcessor(cookiejar)
|
||||
http_client = build_opener(cookieprocessor)
|
||||
|
||||
admin_token = None
|
||||
id_admin_workspace = "admins"
|
||||
|
||||
|
||||
login_form = {"email": "admin@windmill.dev", "password": "changeme"}
|
||||
login_req = Request(
|
||||
"http://localhost:8001/api/auth/login",
|
||||
method="POST",
|
||||
headers={'Content-Type': 'application/json'},
|
||||
data=json.dumps(login_form).encode('utf-8')
|
||||
)
|
||||
with http_client.open(login_req) as response:
|
||||
assert 200 == response.status, f"Failure {response.status}: Superuser login"
|
||||
assert any(cookie.name == "token" for cookie in cookiejar)
|
||||
admin_token = next(cookie.value for cookie in cookiejar if cookie.name == "token")
|
||||
assert admin_token, "Failed to receive a session key from admin login"
|
||||
|
||||
|
||||
if "python" in args.language:
|
||||
# Windmill package recipe requires a manually set default python version in the global instance settings
|
||||
python_version_form = {
|
||||
# NOTE; Update hardcoded python version below to match the windmill package
|
||||
"value": "3.12"
|
||||
}
|
||||
python_version_req = Request(
|
||||
"http://localhost:8001/api/settings/global/instance_python_version",
|
||||
method="GET",
|
||||
headers={'Authorization': f'Bearer {admin_token}'},
|
||||
data=json.dumps(python_version_form).encode('utf-8')
|
||||
)
|
||||
with http_client.open(python_version_req) as response:
|
||||
assert 200 == response.status, f"Failure {response.status}: Update global instance python version."
|
||||
|
||||
|
||||
workspace_req = Request(
|
||||
"http://localhost:8001/api/workspaces/list_as_superadmin",
|
||||
method="GET",
|
||||
headers={'Authorization': f'Bearer {admin_token}'}
|
||||
)
|
||||
with http_client.open(workspace_req) as response:
|
||||
assert 200 == response.status, f"Failure {response.status}: List workspaces"
|
||||
workspace_list = json.loads(response.read().decode('utf-8'))
|
||||
assert any(workspace['id'] == id_admin_workspace for workspace in workspace_list)
|
||||
|
||||
|
||||
script_hash = None
|
||||
script_form = {
|
||||
"path": f"u/admin/{args.language}_test",
|
||||
"summary": f"Test {args.language}",
|
||||
"description": "",
|
||||
"language": args.language,
|
||||
"content": args.content_file.read_text()
|
||||
}
|
||||
script_request = Request(
|
||||
f"http://localhost:8001/api/w/{id_admin_workspace}/scripts/create",
|
||||
method="POST",
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {admin_token}',
|
||||
},
|
||||
data=json.dumps(script_form).encode('utf-8')
|
||||
)
|
||||
with http_client.open(script_request) as response:
|
||||
assert 201 == response.status, f"Failure {response.status}: Create {args.language} script"
|
||||
script_hash = response.read().decode('utf-8')
|
||||
assert script_hash, "Failed to receive an identifier from script creation."
|
||||
|
||||
# NOTE; Some languages require dependencies and the depenceny collection tasks take some time to complete
|
||||
if "bash" not in args.language:
|
||||
for i in [1, 2, 3, 4, 5, 6]:
|
||||
time.sleep(10) # seconds
|
||||
script_request = Request(
|
||||
f"http://localhost:8001/api/w/{id_admin_workspace}/scripts/deployment_status/h/{script_hash}",
|
||||
method="GET",
|
||||
)
|
||||
with http_client.open(script_request) as response:
|
||||
try:
|
||||
assert 200 == response.status, f"Failure {response.status}: Retrieve {args.language} deployment status"
|
||||
script_metadata = json.loads(response.read().decode('utf-8'))
|
||||
#
|
||||
exists_lock_error = bool(script_metadata["lock_error_logs"])
|
||||
assert not exists_lock_error, "Script deployment did not succeed"
|
||||
is_deployment_success = script_metadata["lock"] is not None
|
||||
if is_deployment_success:
|
||||
break
|
||||
except AssertionError:
|
||||
# Re-raise error on final attempt
|
||||
if i == 6:
|
||||
raise
|
||||
|
||||
job_id = None
|
||||
request = Request(
|
||||
f"http://localhost:8001/api/w/{id_admin_workspace}/jobs/run/h/{script_hash}",
|
||||
method="POST",
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {admin_token}',
|
||||
},
|
||||
data=args.input_file.read_bytes(),
|
||||
)
|
||||
with http_client.open(request) as response:
|
||||
assert 201 == response.status, f"Failure {response.status}: Run {args.language} script"
|
||||
job_id = response.read().decode('utf-8')
|
||||
assert job_id, "Failed to receive an identifier from job creation/scheduling."
|
||||
|
||||
|
||||
started_jobs = set([job_id])
|
||||
# NOTE; Some languages require script compilation and take longer to run until completion
|
||||
timeout = 60 # seconds
|
||||
timeout_end = time.time() + timeout
|
||||
while any(started_jobs) and time.time() < timeout_end:
|
||||
time.sleep(10) # seconds
|
||||
|
||||
retrieve_jobs_req = Request(
|
||||
f"http://localhost:8001/api/w/{id_admin_workspace}/jobs/completed/list",
|
||||
method="GET",
|
||||
headers={
|
||||
'Authorization': f'Bearer {admin_token}',
|
||||
},
|
||||
)
|
||||
with http_client.open(retrieve_jobs_req) as response:
|
||||
assert 200 == response.status, f"Failure {response.status}: Retrieve jobs"
|
||||
job_results = json.loads(response.read().decode('utf-8'))
|
||||
for id in set(started_jobs): # Must create copy of set being iterated over
|
||||
if any(job['id'] == id for job in job_results if bool(job['success'])):
|
||||
started_jobs.remove(id)
|
||||
|
||||
if any(started_jobs):
|
||||
# There are started jobs that have not completed before timeout
|
||||
exit(1)
|
||||
@@ -0,0 +1 @@
|
||||
{"dflt": "default value","msg": ""}
|
||||
@@ -0,0 +1,10 @@
|
||||
# shellcheck shell=bash
|
||||
# arguments of the form X="$I" are parsed as parameters X of type string
|
||||
msg="$1"
|
||||
dflt="${2:-default value}"
|
||||
|
||||
# WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
# the last line of the stdout is the return value
|
||||
# unless you write json to './result.json' or a string to './result.out'
|
||||
echo "Hello $msg"
|
||||
@@ -0,0 +1 @@
|
||||
{"b":"my","e":"inferred type string from default arg","f":{"nested":"object"},"g":{"label":"Variant 1","foo":""}}
|
||||
@@ -0,0 +1,28 @@
|
||||
// WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
// import { toWords } from "number-to-words@1"
|
||||
// import * as wmill from "windmill-client"
|
||||
|
||||
// fill the type, or use the +Resource type to get a type-safe reference to a resource
|
||||
// type Postgresql = object
|
||||
|
||||
|
||||
export async function main(
|
||||
a: number,
|
||||
b: "my" | "enum",
|
||||
//c: Postgresql,
|
||||
//d: wmill.S3Object, // https://www.windmill.dev/docs/core_concepts/persistent_storage/large_data_files
|
||||
//d: DynSelect_foo, // https://www.windmill.dev/docs/core_concepts/json_schema_and_parsing#dynamic-select
|
||||
e = "inferred type string from default arg",
|
||||
f = { nested: "object" },
|
||||
g: {
|
||||
label: "Variant 1",
|
||||
foo: string
|
||||
} | {
|
||||
label: "Variant 2",
|
||||
bar: number
|
||||
}
|
||||
) {
|
||||
// let x = await wmill.getVariable('u/user/foo')
|
||||
return { foo: a };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"extraWords": [],"highNumberThreshold": 50,"word": "clue"}
|
||||
@@ -0,0 +1,40 @@
|
||||
// WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
class Script
|
||||
{
|
||||
public static int Main(string[] extraWords, string word = "clue", int highNumberThreshold = 50)
|
||||
{
|
||||
Console.WriteLine("Hello, World!");
|
||||
|
||||
Console.WriteLine("Your chosen words are:");
|
||||
|
||||
string[] newWordArray = extraWords.Concat(new[] { word }).ToArray();
|
||||
|
||||
foreach (var s in newWordArray)
|
||||
{
|
||||
Console.WriteLine($" {s}");
|
||||
}
|
||||
|
||||
var random = new Random();
|
||||
int randomNumber = random.Next(1, 101);
|
||||
|
||||
Console.WriteLine($"Random number: {randomNumber}");
|
||||
|
||||
string greeting = randomNumber > highNumberThreshold ? "High number!" : "Low number!";
|
||||
greeting += " (according to the threshold parameter)";
|
||||
Console.WriteLine(greeting);
|
||||
|
||||
var timespan = TimeSpan.FromMinutes(90);
|
||||
Console.WriteLine($"Timespan: {timespan}");
|
||||
|
||||
int number = 123;
|
||||
Console.WriteLine($"Number: {number}");
|
||||
|
||||
var date = DateTime.UtcNow.AddDays(-3);
|
||||
Console.WriteLine($"Date: {date}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{ runTest, ... }@testArgs:
|
||||
{
|
||||
apiIntegration = runTest ./api-integration.nix;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"b":"my","d":"inferred type string from default arg","e":{"nested":"object"}}
|
||||
@@ -0,0 +1,19 @@
|
||||
// WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
// Deno uses "npm:" prefix to import from npm (https://deno.land/manual@v1.36.3/node/npm_specifiers)
|
||||
// import * as wmill from "npm:windmill-client@1.549.1"
|
||||
|
||||
// fill the type, or use the +Resource type to get a type-safe reference to a resource
|
||||
// type Postgresql = object
|
||||
|
||||
export async function main(
|
||||
a: number,
|
||||
b: "my" | "enum",
|
||||
//c: Postgresql,
|
||||
d = "inferred type string from default arg",
|
||||
e = { nested: "object" },
|
||||
//e: wmill.Base64
|
||||
) {
|
||||
// let x = await wmill.getVariable('u/user/foo')
|
||||
return { foo: a };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"x":"","nested":{"foo":""}}
|
||||
@@ -0,0 +1,23 @@
|
||||
package inner
|
||||
|
||||
// WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
import (
|
||||
"fmt"
|
||||
// "rsc.io/quote"
|
||||
// wmill "github.com/windmill-labs/windmill-go-client"
|
||||
)
|
||||
|
||||
// Pin dependencies partially in go.mod with a comment starting with "//require":
|
||||
//require rsc.io/quote v1.5.1
|
||||
|
||||
// the main must return (interface{}, error)
|
||||
|
||||
func main(x string, nested struct {
|
||||
Foo string `json:"foo"`
|
||||
}) (interface{}, error) {
|
||||
fmt.Println("Hello, World")
|
||||
fmt.Println(nested.Foo)
|
||||
//fmt.Println(quote.Opt())
|
||||
// v, _ := wmill.GetVariable("f/examples/secret")
|
||||
return x, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"d": 123,"e": "default value","f": 3.5,"g": true}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
// WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
function main(
|
||||
// Postgresql $a,
|
||||
// array $b,
|
||||
// object $c,
|
||||
int $d = 123,
|
||||
string $e = "default value",
|
||||
float $f = 3.5,
|
||||
bool $g = true,
|
||||
) {
|
||||
return $d;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"Dflt": "default value","Msg": "","Nb": 3}
|
||||
@@ -0,0 +1,6 @@
|
||||
param($Msg, $Dflt = "default value", [int]$Nb = 3)
|
||||
|
||||
# WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
# the last line of the stdout is the return value
|
||||
Write-Output "Hello $Msg"
|
||||
@@ -0,0 +1 @@
|
||||
{"no_default":"Value","name":"General Zod","age":42,"obj":{"even":"dicts"},"l":["or","lists!"]}
|
||||
@@ -0,0 +1,39 @@
|
||||
# WARN; Cannot download dependency modules from internet during sandbox testing!
|
||||
|
||||
import os
|
||||
# import wmill
|
||||
|
||||
def main(
|
||||
no_default: str,
|
||||
#db: postgresql,
|
||||
name="my friend",
|
||||
age=42,
|
||||
obj: dict = {"even": "dicts"},
|
||||
l: list = ["or", "lists!"],
|
||||
file_: bytes = bytes(0),
|
||||
):
|
||||
|
||||
print(f"Hello World and a warm welcome especially to {name}")
|
||||
print("and its acolytes..", age, obj, l, len(file_))
|
||||
|
||||
# retrieve variables, resources, states using the wmill client
|
||||
try:
|
||||
secret = wmill.get_variable("f/examples/secret")
|
||||
except:
|
||||
secret = "No secret yet at f/examples/secret !"
|
||||
print(f"The variable at `f/examples/secret`: {secret}")
|
||||
|
||||
try:
|
||||
# Get last state of this script execution by the same trigger/user
|
||||
last_state = wmill.get_state()
|
||||
new_state = {"foo": 42} if last_state is None else last_state
|
||||
new_state["foo"] += 1
|
||||
wmill.set_state(new_state)
|
||||
except:
|
||||
new_state = {"foo": 43}
|
||||
|
||||
# fetch context variables
|
||||
user = os.environ.get("WM_USERNAME")
|
||||
|
||||
# return value is converted to JSON
|
||||
return {"splitted": name.split(), "user": user, "state": new_state}
|
||||
Reference in New Issue
Block a user