nixos/windmill: Add module test

This commit is contained in:
Bert Proesmans
2025-11-10 12:41:39 +00:00
committed by Bert Proesmans
parent 02fb5aea55
commit df1c0930f3
20 changed files with 426 additions and 0 deletions
+4
View File
@@ -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;
+75
View File
@@ -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}")
'';
}
+155
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
{"dflt": "default value","msg": ""}
+10
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
{"b":"my","e":"inferred type string from default arg","f":{"nested":"object"},"g":{"label":"Variant 1","foo":""}}
+28
View File
@@ -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 };
}
+1
View File
@@ -0,0 +1 @@
{"extraWords": [],"highNumberThreshold": 50,"word": "clue"}
+40
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
{ runTest, ... }@testArgs:
{
apiIntegration = runTest ./api-integration.nix;
}
+1
View File
@@ -0,0 +1 @@
{"b":"my","d":"inferred type string from default arg","e":{"nested":"object"}}
+19
View File
@@ -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 };
}
+1
View File
@@ -0,0 +1 @@
{"x":"","nested":{"foo":""}}
+23
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
{"d": 123,"e": "default value","f": 3.5,"g": true}
+15
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
{"Dflt": "default value","Msg": "","Nb": 3}
+6
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
{"no_default":"Value","name":"General Zod","age":42,"obj":{"even":"dicts"},"l":["or","lists!"]}
+39
View File
@@ -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}