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