diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 15f15e2fc212..c48f94ac1674 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -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; diff --git a/nixos/tests/windmill/api-integration.nix b/nixos/tests/windmill/api-integration.nix new file mode 100644 index 000000000000..60d2f5276f26 --- /dev/null +++ b/nixos/tests/windmill/api-integration.nix @@ -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 = '' + + + + + + + ''; + }; + }; + + 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 /.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}") + ''; +} diff --git a/nixos/tests/windmill/api-integration.py b/nixos/tests/windmill/api-integration.py new file mode 100755 index 000000000000..fd1112eb403f --- /dev/null +++ b/nixos/tests/windmill/api-integration.py @@ -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) diff --git a/nixos/tests/windmill/bash.input b/nixos/tests/windmill/bash.input new file mode 100644 index 000000000000..2b1d23190296 --- /dev/null +++ b/nixos/tests/windmill/bash.input @@ -0,0 +1 @@ +{"dflt": "default value","msg": ""} diff --git a/nixos/tests/windmill/bash.script b/nixos/tests/windmill/bash.script new file mode 100644 index 000000000000..7cde98cb368c --- /dev/null +++ b/nixos/tests/windmill/bash.script @@ -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" diff --git a/nixos/tests/windmill/bun.input b/nixos/tests/windmill/bun.input new file mode 100644 index 000000000000..2011a93232e5 --- /dev/null +++ b/nixos/tests/windmill/bun.input @@ -0,0 +1 @@ +{"b":"my","e":"inferred type string from default arg","f":{"nested":"object"},"g":{"label":"Variant 1","foo":""}} diff --git a/nixos/tests/windmill/bun.script b/nixos/tests/windmill/bun.script new file mode 100644 index 000000000000..3427312ce32b --- /dev/null +++ b/nixos/tests/windmill/bun.script @@ -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 }; +} diff --git a/nixos/tests/windmill/csharp.input b/nixos/tests/windmill/csharp.input new file mode 100644 index 000000000000..252fc2f0f342 --- /dev/null +++ b/nixos/tests/windmill/csharp.input @@ -0,0 +1 @@ +{"extraWords": [],"highNumberThreshold": 50,"word": "clue"} diff --git a/nixos/tests/windmill/csharp.script b/nixos/tests/windmill/csharp.script new file mode 100644 index 000000000000..ec37677aec3f --- /dev/null +++ b/nixos/tests/windmill/csharp.script @@ -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; + } +} diff --git a/nixos/tests/windmill/default.nix b/nixos/tests/windmill/default.nix new file mode 100644 index 000000000000..5f4ffa66467f --- /dev/null +++ b/nixos/tests/windmill/default.nix @@ -0,0 +1,4 @@ +{ runTest, ... }@testArgs: +{ + apiIntegration = runTest ./api-integration.nix; +} diff --git a/nixos/tests/windmill/deno.input b/nixos/tests/windmill/deno.input new file mode 100644 index 000000000000..59d5df21429b --- /dev/null +++ b/nixos/tests/windmill/deno.input @@ -0,0 +1 @@ +{"b":"my","d":"inferred type string from default arg","e":{"nested":"object"}} diff --git a/nixos/tests/windmill/deno.script b/nixos/tests/windmill/deno.script new file mode 100644 index 000000000000..fc227bf6a17a --- /dev/null +++ b/nixos/tests/windmill/deno.script @@ -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 }; +} diff --git a/nixos/tests/windmill/go.input b/nixos/tests/windmill/go.input new file mode 100644 index 000000000000..2e64fec3bca5 --- /dev/null +++ b/nixos/tests/windmill/go.input @@ -0,0 +1 @@ +{"x":"","nested":{"foo":""}} diff --git a/nixos/tests/windmill/go.script b/nixos/tests/windmill/go.script new file mode 100644 index 000000000000..8c3853cf3778 --- /dev/null +++ b/nixos/tests/windmill/go.script @@ -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 +} diff --git a/nixos/tests/windmill/php.input b/nixos/tests/windmill/php.input new file mode 100644 index 000000000000..ef7140d2401b --- /dev/null +++ b/nixos/tests/windmill/php.input @@ -0,0 +1 @@ +{"d": 123,"e": "default value","f": 3.5,"g": true} diff --git a/nixos/tests/windmill/php.script b/nixos/tests/windmill/php.script new file mode 100644 index 000000000000..5709458fdb3c --- /dev/null +++ b/nixos/tests/windmill/php.script @@ -0,0 +1,15 @@ +