aboutsummaryrefslogtreecommitdiffstats
path: root/tests/default.nix
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/default.nix327
1 files changed, 326 insertions, 1 deletions
diff --git a/tests/default.nix b/tests/default.nix
index d9b354a..88ea7d9 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -3,11 +3,336 @@
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 =
+ specialArgs.extraModules
+ ++ [
+ ./common/acme/client.nix
+ ./common/dns/client.nix
+
+ ../modules
+ ];
+ })
+ args.nodes)
+ ];
+
+ testScript = {nodes, ...} @ testScriptInput: let
+ acme = import ./common/acme {inherit pkgs;};
+
+ waitForServices = builtins.concatStringsSep "\n" (
+ builtins.map (attr: let
+ split = splitAttr attr;
+ name =
+ if builtins.isAttrs split.value
+ then split.value.name
+ else split.value;
+ should_be_started =
+ if builtins.isAttrs split.value
+ then
+ if split.value.should_be_started
+ then "True"
+ else "False"
+ else "False";
+ in " maybe_wait(${split.name},\"${name}\", ${should_be_started})")
+ 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 </dev/null")
+ return ("Done", None, None)
+
+ case (_, "running") | (_, "start"):
+ # It's currently running, let's wait
+ return ("Wait", unitRenew, host)
+
+ case other:
+ assert False, f"Wrong unit sub-states for ({unitMain},{unitRenew}): {other}"
+ assert_never() # i.e. unreachable
+
+ with subtest("Ensure, that all acme certificates have been fetched"):
+ renewRan = {}
+ while True:
+ status = []
+ ${acmeOnline}
+ done = True
+ for (state, unit, host) in status:
+ if state == "Wait":
+ run_and_wait(host, unit)
+ renewRan[unit] = True
+ if state != "Done":
+ done = False
+ if done:
+ break
+ ''
+ + optional (args.services != [])
+ # Python
+ ''
+ def maybe_wait(host, unit: str, should_start: bool):
+ if unit_exists(host, unit):
+ if should_start:
+ host.start_job(unit.removesuffix(".service"))
+ run_and_wait(host, unit)
+ else:
+ host.wait_for_unit(unit)
+ else:
+ assert False, f"Unit '{unit}' does not exist, test misconfigured"
+
+ with subtest("Waiting for units"):
+ ${waitForServices}
+ ''
+ + optional (args.ports != [])
+ # Python
+ ''
+ with subtest("Waiting for ports"):
+ ${waitForPorts}
+ ''
+ +
+ # Python
+ ''
+ def all_services_running(host):
+ import json
+ (status, output) = host.systemctl("list-units --state=failed --plain --no-pager --output=json")
+ host_failed = json.loads(output)
+ assert len(host_failed) == 0, f"Expected zero failing services, but found: {json.dumps(host_failed, indent=4)}"
+
+ with subtest("All services running"):
+ ${allRunning}
+
+ ${(args.testScript testScriptInput)}
+
+ with subtest("All services running"):
+ ${allRunning}
+ ''
+ );
+ };
+
tests = nixLib.mkByName {
baseDirectory = ./by-name;
fileName = "test.nix";
finalizeFunction = name: value:
- import value (nixLib.warnMerge specialArgs {inherit pkgs;} "the test args set");
+ import value (nixLib.warnMerge specialArgs {
+ inherit pkgs vhack;
+ inherit (pkgs) lib;
+ } "the test args set");
};
in
tests