summaryrefslogtreecommitdiff
path: root/nixos/tests/dsl.nix
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/tests/dsl.nix')
-rw-r--r--nixos/tests/dsl.nix247
1 files changed, 247 insertions, 0 deletions
diff --git a/nixos/tests/dsl.nix b/nixos/tests/dsl.nix
new file mode 100644
index 0000000..d11a231
--- /dev/null
+++ b/nixos/tests/dsl.nix
@@ -0,0 +1,247 @@
+{
+ lib,
+ config,
+ pkgs,
+ ...
+}:
+
+let
+ Dependency =
+ { name, ... }:
+ {
+ options.name = lib.mkOption {
+ type = lib.types.str;
+ default = name;
+ };
+ options.runtime = lib.mkEnableOption "Keep a reference in the output store path to retain a runtime dependency";
+ };
+ Node = (
+ { name, ... }:
+ {
+ options.name = lib.mkOption {
+ type = lib.types.str;
+ default = name;
+ };
+ options.request = lib.mkEnableOption "Whether to mark the node for building";
+ options.needed = lib.mkOption {
+ type = with lib.types; nullOr bool;
+ default = null;
+ description = "Whether the node must be built to satisfy all requests (either a requested node or a transitive dependency)";
+ };
+ options.chosen = lib.mkOption {
+ type = with lib.types; nullOr bool;
+ default = null;
+ description = "Whether the node is included in the build plan (i.t. it's `needed` and fitted into budget)";
+ };
+ options.cache = lib.mkOption {
+ type = lib.types.enum [
+ "none"
+ "remote"
+ "local"
+ ];
+ description = ''
+ Whether the dependency is pre-built and available in the local /nix/store ("local"), can be substituted ("remote"), or has to be built ("none")
+ '';
+ default = "none";
+ };
+ options.inputs = lib.mkOption {
+ type = lib.types.attrsOf (lib.types.submodule Dependency);
+ default = { };
+ };
+ }
+ );
+ Nodes = lib.types.attrsOf (lib.types.submodule Node);
+ scope-fun = import ./scope-fun.nix {
+ inherit lib;
+ inherit (config.dag) nodes;
+ };
+in
+{
+ options.dag = {
+ nodes = lib.mkOption {
+ type = Nodes;
+ description = "Derivation DAG, including cache status and references.";
+ };
+ needBuilds = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many builds are required to satisfy all targets. Null disables the test";
+ };
+ needDownloads = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many downloads are required to satisfy all targets. Null disables the test";
+ };
+ choseBuilds = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many builds we expect evanix to choose to satisfy the maximum number of targets within the given budget. Null disables the test";
+ };
+ choseDownloads = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many downloads we expect evanix to choose to satisfy the maximum number of targets within the given budget. Null disables the test";
+ };
+ allowBuilds = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many builds evanix is allowed to choose. Null means no constraint";
+ };
+ allowDownloads = lib.mkOption {
+ type = with lib.types; nullOr int;
+ default = null;
+ description = "How many downloads evanix is allowed to choose. Null means no constraint";
+ };
+ };
+
+ config.nodes.builder =
+ { pkgs, ... }:
+ let
+ scope = pkgs.lib.makeScope pkgs.newScope scope-fun;
+ configJson = (pkgs.formats.json { }).generate "nix-dag.json" config.dag;
+ expressions = pkgs.writeText "guest-scope.nix" ''
+ let
+ pkgs = import ${pkgs.path} { };
+ config = builtins.fromJSON (builtins.readFile ${configJson});
+ in
+ pkgs.lib.makeScope pkgs.newScope (pkgs.callPackage ${./scope-fun.nix} { inherit (pkgs) lib; inherit (config) nodes; })
+ '';
+ tester = pkgs.writers.writePython3Bin "dag-test" { } ''
+ # flake8: noqa
+
+ import json
+ import re
+ import subprocess
+ import sys
+
+ with open("${configJson}", "r") as f:
+ config = json.load(f)
+
+ nodes = config["nodes"]
+ print(f"config={config}", file=sys.stderr)
+
+
+ def path_to_name(path: str) -> str:
+ return re.sub(r"^[ ]*${builtins.storeDir}/[a-z0-9]*-([a-zA-Z0-9_-]+)(\.drv)?", r"\1", path)
+
+ def parse_dry_run(output):
+ to_fetch = [ ]
+ to_build = [ ]
+
+ bin = "undefined"
+ for line in output.split("\n"):
+
+ if not line:
+ continue
+
+ if re.match("^.*will be built:$", line):
+ bin = "to_build"
+ continue
+ elif re.match("^.*will be fetched.*:$", line):
+ bin = "to_fetch"
+ continue
+
+ if not re.match("^[ ]*${builtins.storeDir}", line):
+ print(f"Skipping line: {line}", file=sys.stderr)
+ continue
+
+ line = path_to_name(line)
+
+ if bin == "to_build":
+ to_build.append(line)
+ elif bin == "to_fetch":
+ to_fetch.append(line)
+ else:
+ raise RuntimeError("nix-build --dry-run produced invalid output", line)
+ return to_fetch, to_build
+
+ drv_to_schedule = {}
+ for name, node in nodes.items():
+ p = subprocess.run(["nix-build", "${expressions}", "--dry-run", "--show-trace", "-A", name], check=True, stderr=subprocess.PIPE)
+ output = p.stderr.decode("utf-8")
+ to_fetch, to_build = parse_dry_run(output)
+ drv_to_schedule[name] = (to_fetch, to_build)
+
+ drv_to_action = {}
+ for (to_fetch, to_build) in drv_to_schedule.values():
+ for dep in to_fetch:
+ name = path_to_name(dep)
+ if name not in drv_to_action:
+ drv_to_action[name] = "fetch"
+ assert drv_to_action[name] == "fetch", f"Conflicting plan for {dep}"
+ for dep in to_build:
+ name = path_to_name(dep)
+ if name not in drv_to_action:
+ drv_to_action[name] = "build"
+ assert drv_to_action[name] == "build", f"Conflicting plan for {dep}"
+
+ print(f"Schedule: {drv_to_action}", file=sys.stderr)
+ print(f"Per-derivation schedules: {drv_to_schedule}", file=sys.stderr)
+
+ for name, node in nodes.items():
+ error_msg = f"Wrong plan for {name}"
+ if node["cache"] == "local":
+ assert name not in drv_to_action, error_msg
+ elif node["cache"] == "remote":
+ assert drv_to_action.get(name, None) == "fetch", error_msg
+ elif node["cache"] == "unbuilt":
+ assert drv_to_action.get(name, None) == "build", error_msg
+
+ need_dls, need_builds = set(), set()
+ for name, node in nodes.items():
+ if node["request"]:
+ need_dls.update(drv_to_schedule[name][0])
+ need_builds.update(drv_to_schedule[name][1])
+
+ if (expected_need_dls := config.get("needDownloads", None)) is not None:
+ assert len(need_dls) == expected_need_dls, f"{len(need_dls)} != {expected_need_dls}; building {need_dls}"
+ print("Verified `needDownloads`", file=sys.stderr)
+
+ if (expected_need_builds := config.get("needBuilds", None)) is not None:
+ assert len(need_builds) == expected_need_builds, f"{len(need_builds)} != {expected_need_builds}; building {need_builds}"
+ print("Verified `needBuilds`", file=sys.stderr)
+ '';
+ in
+ {
+ system.extraDependencies =
+ lib.pipe config.dag.nodes [
+ builtins.attrValues
+ (builtins.filter ({ cache, ... }: cache == "local"))
+ (builtins.map ({ name, ... }: scope.${name}))
+ ]
+ ++ [
+ expressions
+ pkgs.path
+ (pkgs.runCommand "any-run-command" { } "").inputDerivation
+ ];
+ networking.hostName = "builder";
+ networking.domain = "evanix-tests.local";
+ nix.settings.substituters = lib.mkForce [ "http://substituter" ];
+ systemd.tmpfiles.settings."10-expressions" = {
+ "/run/dag-test/nix-dag-test.json"."L+".argument = "${configJson}";
+ "/run/dag-test/scope.nix"."L+".argument = "${expressions}";
+ };
+ environment.systemPackages = [ tester ];
+ };
+ config.nodes.substituter =
+ { pkgs, ... }:
+ let
+ scope = pkgs.lib.makeScope pkgs.newScope scope-fun;
+ in
+ {
+ system.extraDependencies = lib.pipe config.dag.nodes [
+ builtins.attrValues
+ (builtins.filter ({ cache, ... }: cache == "remote"))
+ (builtins.map ({ name, ... }: scope.${name}))
+ ];
+ services.nix-serve.enable = true;
+ services.nix-serve.port = 80;
+ services.nix-serve.openFirewall = true;
+
+ # Allow listening on 80
+ systemd.services.nix-serve.serviceConfig.User = lib.mkForce "root";
+ networking.hostName = "substituter";
+
+ networking.domain = "evanix-tests.local";
+ };
+}