1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
|
{ lib, config, ... }:
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.assertNeeded = 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.assertChosen = 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 [
"unbuilt"
"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 = "unbuilt";
};
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
evanix = pkgs.callPackage ../../package.nix { };
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; })
'';
requestExpressions = pkgs.writeText "guest-request-scope.nix" ''
let
pkgs = import ${pkgs.path} { };
config = builtins.fromJSON (builtins.readFile ${configJson});
testPkgs = import ${expressions};
in
pkgs.lib.attrsets.filterAttrs (name: node: !(config.nodes ? ''${name}) || (config.nodes.''${name} ? request && config.nodes.''${name}.request)) testPkgs
'';
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_evanix_dry_run(output):
to_build = [ ]
for line in output.split("\n"):
if not re.match("nix-build --out-link .*$", line):
continue
drv = re.sub(r"^nix-build --out-link result-([a-zA-Z0-9_-]+).*$", r"\1", line)
to_build.append(drv)
return to_build
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
nix_build_needed = set()
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)
nix_build_needed.update(to_build)
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}"
action = drv_to_action.get(name, None)
if node["cache"] == "local":
assert action is None, error_msg
elif node["cache"] == "remote":
assert action == "fetch", error_msg
elif node["cache"] == "unbuilt":
assert action == "build", error_msg
else:
raise AssertionError('cache is not in [ "local", "remote", "unbuilt" ]')
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)
assertNeededNodes = set()
for name, node in nodes.items():
if "assertNeeded" in node and node["assertNeeded"]:
assertNeededNodes.add(name)
if assertNeededNodes:
for name in assertNeededNodes:
assert name in nix_build_needed, f"{name}.assertNeeded failed"
print("Verified `assertNeededNodes`", file=sys.stderr)
assertChosenNodes = set()
for name, node in nodes.items():
if "assertChosen" in node and node["assertChosen"]:
assertChosenNodes.add(name)
evanix_args = ["evanix", "${requestExpressions}", "--dry-run", "--close-unused-fd", "false"]
if config.get("allowBuilds", None) is not None:
evanix_args.extend(["--solver=highs", "--max-build", str(config["allowBuilds"])])
if assertChosenNodes or config.get("choseBuilds", None) is not None:
evanix = subprocess.run(evanix_args, check=True, stdout=subprocess.PIPE)
evanix_output = evanix.stdout.decode("utf-8")
evanix_builds = parse_evanix_dry_run(evanix_output)
if config.get("choseBuilds", None) is not None:
assert len(evanix_builds) == config["choseBuilds"], f"len({evanix_builds}) != choseBuilds"
print("Verified `choseBuilds`", file=sys.stderr)
if assertChosenNodes:
for name in assertChosenNodes:
assert name in evanix_builds and name in nix_build_needed, f"{name}.assertChosen failed"
print("Verified `assertChosenNodes`", file=sys.stderr)
'';
in
{
system.extraDependencies =
lib.pipe config.dag.nodes [
builtins.attrValues
(builtins.filter ({ cache, ... }: cache == "local"))
(builtins.map ({ name, ... }: scope.${name}))
]
++ [
pkgs.path
# Cache runCommand's dependencies such as runtimeShell
(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}";
"/run/dag-test/requestScope.nix"."L+".argument = "${requestExpressions}";
};
environment.systemPackages = [
tester
evanix
];
};
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";
};
}
|