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