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