1{ config, lib, pkgs, ... }:
2
3let
4 cfg = config.services.buildkite-agents;
5
6 hooksDir = hooks:
7 let
8 mkHookEntry = name: text: ''
9 ln --symbolic ${pkgs.writeShellApplication { inherit name text; }}/bin/${name} $out/${name}
10 '';
11 in
12 pkgs.runCommandLocal "buildkite-agent-hooks" { } ''
13 mkdir $out
14 ${lib.concatStringsSep "\n" (lib.mapAttrsToList mkHookEntry hooks)}
15 '';
16
17 buildkiteOptions = { name ? "", config, ... }: {
18 options = {
19 enable = lib.mkOption {
20 default = true;
21 type = lib.types.bool;
22 description = "Whether to enable this buildkite agent";
23 };
24
25 package = lib.mkOption {
26 default = pkgs.buildkite-agent;
27 defaultText = lib.literalExpression "pkgs.buildkite-agent";
28 description = "Which buildkite-agent derivation to use";
29 type = lib.types.package;
30 };
31
32 dataDir = lib.mkOption {
33 default = "/var/lib/buildkite-agent-${name}";
34 description = "The workdir for the agent";
35 type = lib.types.str;
36 };
37
38 extraGroups = lib.mkOption {
39 default = [ "keys" ];
40 description = "Groups the user for this buildkite agent should belong to";
41 type = lib.types.listOf lib.types.str;
42 };
43
44 runtimePackages = lib.mkOption {
45 default = [ pkgs.bash pkgs.gnutar pkgs.gzip pkgs.git pkgs.nix ];
46 defaultText = lib.literalExpression "[ pkgs.bash pkgs.gnutar pkgs.gzip pkgs.git pkgs.nix ]";
47 description = "Add programs to the buildkite-agent environment";
48 type = lib.types.listOf lib.types.package;
49 };
50
51 tokenPath = lib.mkOption {
52 type = lib.types.path;
53 description = ''
54 The token from your Buildkite "Agents" page.
55
56 A run-time path to the token file, which is supposed to be provisioned
57 outside of Nix store.
58 '';
59 };
60
61 name = lib.mkOption {
62 type = lib.types.str;
63 default = "%hostname-${name}-%n";
64 description = ''
65 The name of the agent as seen in the buildkite dashboard.
66 '';
67 };
68
69 tags = lib.mkOption {
70 type = lib.types.attrsOf (lib.types.either lib.types.str (lib.types.listOf lib.types.str));
71 default = { };
72 example = { queue = "default"; docker = "true"; ruby2 = "true"; };
73 description = ''
74 Tags for the agent.
75 '';
76 };
77
78 extraConfig = lib.mkOption {
79 type = lib.types.lines;
80 default = "";
81 example = "debug=true";
82 description = ''
83 Extra lines to be added verbatim to the configuration file.
84 '';
85 };
86
87 privateSshKeyPath = lib.mkOption {
88 type = lib.types.nullOr lib.types.path;
89 default = null;
90 ## maximum care is taken so that secrets (ssh keys and the CI token)
91 ## don't end up in the Nix store.
92 apply = final: if final == null then null else toString final;
93
94 description = ''
95 OpenSSH private key
96
97 A run-time path to the key file, which is supposed to be provisioned
98 outside of Nix store.
99 '';
100 };
101
102 hooks = lib.mkOption {
103 type = lib.types.attrsOf lib.types.lines;
104 default = { };
105 example = lib.literalExpression ''
106 {
107 environment = '''
108 export SECRET_VAR=`head -1 /run/keys/secret`
109 ''';
110 }'';
111 description = ''
112 "Agent" hooks to install.
113 See <https://buildkite.com/docs/agent/v3/hooks> for possible options.
114 '';
115 };
116
117 hooksPath = lib.mkOption {
118 type = lib.types.path;
119 default = hooksDir config.hooks;
120 defaultText = lib.literalMD "generated from {option}`services.buildkite-agents.<name>.hooks`";
121 description = ''
122 Path to the directory storing the hooks.
123 Consider using {option}`services.buildkite-agents.<name>.hooks.<name>`
124 instead.
125 '';
126 };
127
128 shell = lib.mkOption {
129 type = lib.types.str;
130 default = "${pkgs.bash}/bin/bash -e -c";
131 defaultText = lib.literalExpression ''"''${pkgs.bash}/bin/bash -e -c"'';
132 description = ''
133 Command that buildkite-agent 3 will execute when it spawns a shell.
134 '';
135 };
136 };
137 };
138 enabledAgents = lib.filterAttrs (n: v: v.enable) cfg;
139 mapAgents = function: lib.mkMerge (lib.mapAttrsToList function enabledAgents);
140in
141{
142 options.services.buildkite-agents = lib.mkOption {
143 type = lib.types.attrsOf (lib.types.submodule buildkiteOptions);
144 default = { };
145 description = ''
146 Attribute set of buildkite agents.
147 The attribute key is combined with the hostname and a unique integer to
148 create the final agent name. This can be overridden by setting the `name`
149 attribute.
150 '';
151 };
152
153 config.users.users = mapAgents (name: cfg: {
154 "buildkite-agent-${name}" = {
155 name = "buildkite-agent-${name}";
156 home = cfg.dataDir;
157 createHome = true;
158 description = "Buildkite agent user";
159 extraGroups = cfg.extraGroups;
160 isSystemUser = true;
161 group = "buildkite-agent-${name}";
162 };
163 });
164 config.users.groups = mapAgents (name: cfg: {
165 "buildkite-agent-${name}" = { };
166 });
167
168 config.systemd.services = mapAgents (name: cfg: {
169 "buildkite-agent-${name}" = {
170 description = "Buildkite Agent";
171 wantedBy = [ "multi-user.target" ];
172 after = [ "network.target" ];
173 path = cfg.runtimePackages ++ [ cfg.package pkgs.coreutils ];
174 environment = config.networking.proxy.envVars // {
175 HOME = cfg.dataDir;
176 NIX_REMOTE = "daemon";
177 };
178
179 ## NB: maximum care is taken so that secrets (ssh keys and the CI token)
180 ## don't end up in the Nix store.
181 preStart =
182 let
183 sshDir = "${cfg.dataDir}/.ssh";
184 tagStr = name: value:
185 if lib.isList value
186 then lib.concatStringsSep "," (builtins.map (v: "${name}=${v}") value)
187 else "${name}=${value}";
188 tagsStr = lib.concatStringsSep "," (lib.mapAttrsToList tagStr cfg.tags);
189 in
190 lib.optionalString (cfg.privateSshKeyPath != null) ''
191 mkdir -m 0700 -p "${sshDir}"
192 install -m600 "${toString cfg.privateSshKeyPath}" "${sshDir}/id_rsa"
193 '' + ''
194 cat > "${cfg.dataDir}/buildkite-agent.cfg" <<EOF
195 token="$(cat ${toString cfg.tokenPath})"
196 name="${cfg.name}"
197 shell="${cfg.shell}"
198 tags="${tagsStr}"
199 build-path="${cfg.dataDir}/builds"
200 hooks-path="${cfg.hooksPath}"
201 ${cfg.extraConfig}
202 EOF
203 '';
204
205 serviceConfig = {
206 ExecStart = "${cfg.package}/bin/buildkite-agent start --config ${cfg.dataDir}/buildkite-agent.cfg";
207 User = "buildkite-agent-${name}";
208 RestartSec = 5;
209 Restart = "on-failure";
210 TimeoutSec = 10;
211 # set a long timeout to give buildkite-agent a chance to finish current builds
212 TimeoutStopSec = "2 min";
213 KillMode = "mixed";
214 };
215 };
216 });
217
218 config.assertions = mapAgents (name: cfg: [{
219 assertion = cfg.hooksPath != hooksDir cfg.hooks -> cfg.hooks == { };
220 message = ''
221 Options `services.buildkite-agents.${name}.hooksPath' and
222 `services.buildkite-agents.${name}.hooks.<name>' are mutually exclusive.
223 '';
224 }]);
225}