1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 jenkinsCfg = config.services.jenkins;
7 cfg = config.services.jenkins.jobBuilder;
8
9in {
10 options = {
11 services.jenkins.jobBuilder = {
12 enable = mkOption {
13 type = types.bool;
14 default = false;
15 description = lib.mdDoc ''
16 Whether or not to enable the Jenkins Job Builder (JJB) service. It
17 allows defining jobs for Jenkins in a declarative manner.
18
19 Jobs managed through the Jenkins WebUI (or by other means) are left
20 unchanged.
21
22 Note that it really is declarative configuration; if you remove a
23 previously defined job, the corresponding job directory will be
24 deleted.
25
26 Please see the Jenkins Job Builder documentation for more info:
27 [
28 http://docs.openstack.org/infra/jenkins-job-builder/](http://docs.openstack.org/infra/jenkins-job-builder/)
29 '';
30 };
31
32 accessUser = mkOption {
33 default = "admin";
34 type = types.str;
35 description = lib.mdDoc ''
36 User id in Jenkins used to reload config.
37 '';
38 };
39
40 accessToken = mkOption {
41 default = "";
42 type = types.str;
43 description = lib.mdDoc ''
44 User token in Jenkins used to reload config.
45 WARNING: This token will be world readable in the Nix store. To keep
46 it secret, use the {option}`accessTokenFile` option instead.
47 '';
48 };
49
50 accessTokenFile = mkOption {
51 default = "${config.services.jenkins.home}/secrets/initialAdminPassword";
52 defaultText = literalExpression ''"''${config.services.jenkins.home}/secrets/initialAdminPassword"'';
53 type = types.str;
54 example = "/run/keys/jenkins-job-builder-access-token";
55 description = lib.mdDoc ''
56 File containing the API token for the {option}`accessUser`
57 user.
58 '';
59 };
60
61 yamlJobs = mkOption {
62 default = "";
63 type = types.lines;
64 example = ''
65 - job:
66 name: jenkins-job-test-1
67 builders:
68 - shell: echo 'Hello world!'
69 '';
70 description = lib.mdDoc ''
71 Job descriptions for Jenkins Job Builder in YAML format.
72 '';
73 };
74
75 jsonJobs = mkOption {
76 default = [ ];
77 type = types.listOf types.str;
78 example = literalExpression ''
79 [
80 '''
81 [ { "job":
82 { "name": "jenkins-job-test-2",
83 "builders": [ "shell": "echo 'Hello world!'" ]
84 }
85 }
86 ]
87 '''
88 ]
89 '';
90 description = lib.mdDoc ''
91 Job descriptions for Jenkins Job Builder in JSON format.
92 '';
93 };
94
95 nixJobs = mkOption {
96 default = [ ];
97 type = types.listOf types.attrs;
98 example = literalExpression ''
99 [ { job =
100 { name = "jenkins-job-test-3";
101 builders = [
102 { shell = "echo 'Hello world!'"; }
103 ];
104 };
105 }
106 ]
107 '';
108 description = lib.mdDoc ''
109 Job descriptions for Jenkins Job Builder in Nix format.
110
111 This is a trivial wrapper around jsonJobs, using builtins.toJSON
112 behind the scene.
113 '';
114 };
115 };
116 };
117
118 config = mkIf (jenkinsCfg.enable && cfg.enable) {
119 assertions = [
120 { assertion =
121 if cfg.accessUser != ""
122 then (cfg.accessToken != "" && cfg.accessTokenFile == "") ||
123 (cfg.accessToken == "" && cfg.accessTokenFile != "")
124 else true;
125 message = ''
126 One of accessToken and accessTokenFile options must be non-empty
127 strings, but not both. Current values:
128 services.jenkins.jobBuilder.accessToken = "${cfg.accessToken}"
129 services.jenkins.jobBuilder.accessTokenFile = "${cfg.accessTokenFile}"
130 '';
131 }
132 ];
133
134 systemd.services.jenkins-job-builder = {
135 description = "Jenkins Job Builder Service";
136 # JJB can run either before or after jenkins. We chose after, so we can
137 # always use curl to notify (running) jenkins to reload its config.
138 after = [ "jenkins.service" ];
139 wantedBy = [ "multi-user.target" ];
140
141 path = with pkgs; [ jenkins-job-builder curl ];
142
143 # Q: Why manipulate files directly instead of using "jenkins-jobs upload [...]"?
144 # A: Because this module is for administering a local jenkins install,
145 # and using local file copy allows us to not worry about
146 # authentication.
147 script =
148 let
149 yamlJobsFile = builtins.toFile "jobs.yaml" cfg.yamlJobs;
150 jsonJobsFiles =
151 map (x: (builtins.toFile "jobs.json" x))
152 (cfg.jsonJobs ++ [(builtins.toJSON cfg.nixJobs)]);
153 jobBuilderOutputDir = "/run/jenkins-job-builder/output";
154 # Stamp file is placed in $JENKINS_HOME/jobs/$JOB_NAME/ to indicate
155 # ownership. Enables tracking and removal of stale jobs.
156 ownerStamp = ".config-xml-managed-by-nixos-jenkins-job-builder";
157 reloadScript = ''
158 echo "Asking Jenkins to reload config"
159 curl_opts="--silent --fail --show-error"
160 access_token_file=${if cfg.accessTokenFile != ""
161 then cfg.accessTokenFile
162 else "$RUNTIME_DIRECTORY/jenkins_access_token.txt"}
163 if [ "${cfg.accessToken}" != "" ]; then
164 (umask 0077; printf "${cfg.accessToken}" >"$access_token_file")
165 fi
166 jenkins_url="http://${jenkinsCfg.listenAddress}:${toString jenkinsCfg.port}${jenkinsCfg.prefix}"
167 auth_file="$RUNTIME_DIRECTORY/jenkins_auth_file.txt"
168 trap 'rm -f "$auth_file"' EXIT
169 (umask 0077; printf "${cfg.accessUser}:@password_placeholder@" >"$auth_file")
170 "${pkgs.replace-secret}/bin/replace-secret" "@password_placeholder@" "$access_token_file" "$auth_file"
171
172 if ! "${pkgs.jenkins}/bin/jenkins-cli" -s "$jenkins_url" -auth "@$auth_file" reload-configuration; then
173 echo "error: failed to reload configuration"
174 exit 1
175 fi
176 '';
177 in
178 ''
179 joinByString()
180 {
181 local separator="$1"
182 shift
183 local first="$1"
184 shift
185 printf "%s" "$first" "''${@/#/$separator}"
186 }
187
188 # Map a relative directory path in the output from
189 # jenkins-job-builder (jobname) to the layout expected by jenkins:
190 # each directory level gets prepended "jobs/".
191 getJenkinsJobDir()
192 {
193 IFS='/' read -ra input_dirs <<< "$1"
194 printf "jobs/"
195 joinByString "/jobs/" "''${input_dirs[@]}"
196 }
197
198 # The inverse of getJenkinsJobDir (remove the "jobs/" prefixes)
199 getJobname()
200 {
201 IFS='/' read -ra input_dirs <<< "$1"
202 local i=0
203 local nelem=''${#input_dirs[@]}
204 for e in "''${input_dirs[@]}"; do
205 if [ $((i % 2)) -eq 1 ]; then
206 printf "$e"
207 if [ $i -lt $(( nelem - 1 )) ]; then
208 printf "/"
209 fi
210 fi
211 i=$((i + 1))
212 done
213 }
214
215 rm -rf ${jobBuilderOutputDir}
216 cur_decl_jobs=/run/jenkins-job-builder/declarative-jobs
217 rm -f "$cur_decl_jobs"
218
219 # Create / update jobs
220 mkdir -p ${jobBuilderOutputDir}
221 for inputFile in ${yamlJobsFile} ${concatStringsSep " " jsonJobsFiles}; do
222 HOME="${jenkinsCfg.home}" "${pkgs.jenkins-job-builder}/bin/jenkins-jobs" --ignore-cache test --config-xml -o "${jobBuilderOutputDir}" "$inputFile"
223 done
224
225 find "${jobBuilderOutputDir}" -type f -name config.xml | while read -r f; do echo "$(dirname "$f")"; done | sort | while read -r dir; do
226 jobname="$(realpath --relative-to="${jobBuilderOutputDir}" "$dir")"
227 jenkinsjobname=$(getJenkinsJobDir "$jobname")
228 jenkinsjobdir="${jenkinsCfg.home}/$jenkinsjobname"
229 echo "Creating / updating job \"$jobname\""
230 mkdir -p "$jenkinsjobdir"
231 touch "$jenkinsjobdir/${ownerStamp}"
232 cp "$dir"/config.xml "$jenkinsjobdir/config.xml"
233 echo "$jenkinsjobname" >> "$cur_decl_jobs"
234 done
235
236 # Remove stale jobs
237 find "${jenkinsCfg.home}" -type f -name "${ownerStamp}" | while read -r f; do echo "$(dirname "$f")"; done | sort --reverse | while read -r dir; do
238 jenkinsjobname="$(realpath --relative-to="${jenkinsCfg.home}" "$dir")"
239 grep --quiet --line-regexp "$jenkinsjobname" "$cur_decl_jobs" 2>/dev/null && continue
240 jobname=$(getJobname "$jenkinsjobname")
241 echo "Deleting stale job \"$jobname\""
242 jobdir="${jenkinsCfg.home}/$jenkinsjobname"
243 rm -rf "$jobdir"
244 done
245 '' + (optionalString (cfg.accessUser != "") reloadScript);
246 serviceConfig = {
247 Type = "oneshot";
248 User = jenkinsCfg.user;
249 RuntimeDirectory = "jenkins-job-builder";
250 };
251 };
252 };
253}