{ specialArgs, nixLib, pkgs, }: let splitAttr = attr: let pairs = builtins.attrNames attr; firstName = builtins.elemAt pairs 0; name = if (builtins.length pairs) == 1 then firstName else builtins.throw "The name value pair (`${firstName}` and some more) has more than one name value pair."; in { inherit name; value = attr.${name}; }; vhack.runTest = { name, # Node definition, like in a normal nixos vm test. nodes ? {}, # Extra DNS configuration with the node's config as input. # Example: # ``` # server1 = {config, ...}: { # "domain.example.org" = { # TXT = [ "test" ]; # }; # }; # ``` # That will add the TXT record to the `domain.example.org` domain, the `config` will # be from the `server1` node. dns ? {}, # Server domains that all clients and the server can resolve via dns. # The format follows the common one, e.g.: # `{nodeName = "domain.example.com";}` # means, that `domain.example.com` resolves to the node with name `nodeName`. serverDomains ? [], # Services to wait for. # The format follows the common one, e.g.: # `{nodeName = "example.service";}` # means, that the test will not start, until `example.service` on the node with name # `nodeName` has started. services ? [], # Open ports to wait for. # The format follows the common one, e.g.: # `{nodeName = 993;}` # means, that the test will not start, until port `993` on the node with name # `nodeName` is opened. ports ? [], # If `true`, will ignore this test. ignore ? null, testScript ? {_, ...}: "", }: let # Apparently the default's (e.g. `serivces ? []`), are not applied for the `@args` # syntax. args = {inherit dns ignore name nodes ports serverDomains services testScript;}; in specialArgs.nixos-lib.runTest { hostPkgs = pkgs; inherit (args) name; meta.broken = pkgs.lib.mkIf (args.ignore != null) true; node = { specialArgs = { inherit (specialArgs) pkgsUnstable vhackPackages nixpkgs-unstable ; inherit nixLib; }; # Use the nixpkgs as constructed by the `nixpkgs.*` options pkgs = null; }; nodes = pkgs.lib.mkMerge [ { acme = { imports = [ ./common/acme/server.nix ./common/dns/client.nix ]; }; name_server = {nodes, ...}: { imports = specialArgs.extraModules ++ [ ./common/acme/client.nix ./common/dns/server.nix ]; vhack.dns.zones = let mkDomain = attr: let split = splitAttr attr; in { name = "${split.value}"; value = { SOA = { nameServer = "ns"; adminEmail = "admin@server.com"; serial = 2025012301; }; useOrigin = false; A = [ nodes.${split.name}.networking.primaryIPAddress ]; AAAA = [ nodes.${split.name}.networking.primaryIPv6Address ]; }; }; in builtins.listToAttrs (builtins.map mkDomain args.serverDomains); }; } (pkgs.lib.mapAttrs' (nodeName: dnsOverride: { name = "name_server"; value = {nodes, ...}: { vhack.dns.zones = dnsOverride {config = nodes."${nodeName}";}; }; }) args.dns) args.nodes (builtins.mapAttrs (_: _: { imports = [ ./common/acme/client.nix ./common/dns/client.nix ]; }) args.nodes) ]; testScript = {nodes, ...} @ testScriptInput: let acme = import ./common/acme {inherit pkgs;}; waitForServices = builtins.concatStringsSep "\n" ( builtins.map (attr: let split = splitAttr attr; in " maybe_wait(${split.name},\"${split.value}\", ${ if (builtins.hasAttr "start" attr && attr.start) then "True" else "False" })") args.services ); waitForPorts = builtins.concatStringsSep "\n" ( builtins.map ( attr: let split = splitAttr attr; in " ${split.name}.wait_for_open_port(${builtins.toString split.value})" ) args.ports ); acmeOnline = builtins.concatStringsSep "\n" (builtins.map (attr: let split = splitAttr attr; in " status.append(acme_online(${split.name}, \"${split.value}\", renewRan.get(\"acme-order-renew-${split.value}.service\", False)))") args.serverDomains); allRunning = builtins.concatStringsSep "\n" (builtins.map (name: " all_services_running(${name})") (builtins.attrNames args.nodes)); optional = condition: value: if condition then value else ""; in acme.prepare (builtins.attrNames args.nodes) # The acme code runs `start_all` before it hands execution back to us. # So we don't need to run it. ( optional (args.services != [] || args.serverDomains != []) # Python '' def unit_exists(host, unitName: str) -> bool: (status, _) = host.execute(f"test -f '/etc/systemd/system/{unitName}'") return status == 0 wasAlive: bool deadCounter: int def run_and_wait(host, unit: str): global wasAlive global deadCounter wasAlive = False deadCounter = 0 def wait_state(_last_try: bool) -> bool: global wasAlive global deadCounter state = host.get_unit_property(unit, "SubState") host.log(f"(waiting) {unit} -> {state}") match state: case "failed": assert False, f'Unit "{unit}" reached state "{state}"' case "start" | "active" | "running": wasAlive = True case "dead": if wasAlive: return True else: if deadCounter >= 5: return True deadCounter = deadCounter + 1 return False return False with host.nested(f"Waiting for unit {unit}"): retry(wait_state) '' + optional (args.serverDomains != []) # Python '' def acme_online(host, domain, renewRan): from typing import assert_never unitMain = f"acme-{domain}.service" unitRenew = f"acme-order-renew-{domain}.service" infoMain = host.get_unit_property(unitMain, "SubState") infoRenew = host.get_unit_property(unitRenew, "SubState") match (infoMain, infoRenew): case ("exited", "dead") | ("dead", "dead") if not renewRan: # The unit might not exist, because we use the DNS values for # generating this code (there is no needed mapping between DNS -> acme # host). So we check, if it is an actual unit. if unit_exists(host, unitMain): host.start_job(f"acme-{domain}") return ("Wait", unitRenew, host) else: return ("Done", None, None) case ("exited", "dead") | ("exited", "dead") if renewRan: # The unit is done, so it should be good? host.require_unit_state(unitMain, "active") host.succeed(f"${pkgs.lib.getExe pkgs.openssl} s_client -connect {domain}:443 -verify_return_error