indraq_cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG_v2.1.0.md +27 -27
  2. package/CHANGELOG_v2.2.0.md +52 -0
  3. package/LICENSE +21 -21
  4. package/README.md +27 -14
  5. package/RELEASE_CHECKS_v2.2.0.md +27 -0
  6. package/UPGRADE_v2.0_to_v2.1.md +68 -68
  7. package/UPGRADE_v2.1_to_v2.2.md +62 -0
  8. package/cloud-api/.dockerignore +18 -18
  9. package/cloud-api/.env.example +14 -14
  10. package/cloud-api/Dockerfile +19 -19
  11. package/cloud-api/README.md +93 -93
  12. package/cloud-api/dist/index.js +265 -27
  13. package/cloud-api/node_modules/.package-lock.json +1 -1
  14. package/cloud-api/package-lock.json +2 -2
  15. package/cloud-api/package.json +1 -1
  16. package/cloud-api/sql/001_init.sql +8 -8
  17. package/cloud-api/sql/002_user_providers_and_project_resources.sql +40 -40
  18. package/cloud-api/sql/003_environment_infrastructure.sql +19 -19
  19. package/cloud-api/sql/004_managed_aws_credentials.sql +20 -20
  20. package/cloud-api/sql/005_project_members_and_resource_grants.sql +22 -22
  21. package/cloud-api/sql/006_catalog_identity_password_security.sql +96 -96
  22. package/cloud-api/sql/007_aws_access_key_rotation.sql +16 -16
  23. package/cloud-api/sql/008_provider_scopes.sql +91 -91
  24. package/cloud-api/sql/009_scope_user_secrets_and_project_lifecycle.sql +13 -0
  25. package/cloud-api/src/index.ts +164 -27
  26. package/cloud-api/tsconfig.json +18 -18
  27. package/dist/cli/help-content.d.ts.map +1 -1
  28. package/dist/cli/help-content.js +11 -5
  29. package/dist/cli/help-content.js.map +1 -1
  30. package/dist/modules/access/commands/access.command.js +30 -30
  31. package/dist/modules/aws/services/route53-lookup.service.js.map +1 -1
  32. package/dist/modules/cloud/commands/cloud.command.d.ts +4 -0
  33. package/dist/modules/cloud/commands/cloud.command.d.ts.map +1 -1
  34. package/dist/modules/cloud/commands/cloud.command.js +52 -1
  35. package/dist/modules/cloud/commands/cloud.command.js.map +1 -1
  36. package/dist/modules/cloud/index.d.ts.map +1 -1
  37. package/dist/modules/cloud/index.js +4 -1
  38. package/dist/modules/cloud/index.js.map +1 -1
  39. package/dist/modules/cloud/services/cloud-jenkins.service.js +71 -71
  40. package/dist/modules/cloud/services/user-provider.service.d.ts +2 -8
  41. package/dist/modules/cloud/services/user-provider.service.d.ts.map +1 -1
  42. package/dist/modules/cloud/services/user-provider.service.js +7 -35
  43. package/dist/modules/cloud/services/user-provider.service.js.map +1 -1
  44. package/dist/modules/deploy/commands/build.command.d.ts +1 -0
  45. package/dist/modules/deploy/commands/build.command.d.ts.map +1 -1
  46. package/dist/modules/deploy/commands/build.command.js +12 -5
  47. package/dist/modules/deploy/commands/build.command.js.map +1 -1
  48. package/dist/modules/deploy/index.d.ts.map +1 -1
  49. package/dist/modules/deploy/index.js +11 -4
  50. package/dist/modules/deploy/index.js.map +1 -1
  51. package/dist/modules/prebuild/commands/prebuild.command.d.ts +0 -4
  52. package/dist/modules/prebuild/commands/prebuild.command.d.ts.map +1 -1
  53. package/dist/modules/prebuild/commands/prebuild.command.js +34 -38
  54. package/dist/modules/prebuild/commands/prebuild.command.js.map +1 -1
  55. package/dist/modules/prebuild/index.d.ts.map +1 -1
  56. package/dist/modules/prebuild/index.js +4 -8
  57. package/dist/modules/prebuild/index.js.map +1 -1
  58. package/dist/modules/scaffold/services/docker-template.service.js +95 -95
  59. package/dist/modules/scopes/commands/scope.command.d.ts.map +1 -1
  60. package/dist/modules/scopes/commands/scope.command.js +3 -47
  61. package/dist/modules/scopes/commands/scope.command.js.map +1 -1
  62. package/dist/modules/scopes/services/scope-native.service.js +2 -2
  63. package/dist/modules/scopes/services/scope-native.service.js.map +1 -1
  64. package/dist/modules/users/commands/user.command.d.ts +1 -0
  65. package/dist/modules/users/commands/user.command.d.ts.map +1 -1
  66. package/dist/modules/users/commands/user.command.js +15 -1
  67. package/dist/modules/users/commands/user.command.js.map +1 -1
  68. package/dist/modules/users/index.d.ts.map +1 -1
  69. package/dist/modules/users/index.js +1 -0
  70. package/dist/modules/users/index.js.map +1 -1
  71. package/dist/modules/users/providers/jenkins-user.provider.js +95 -95
  72. package/dist/shared/config/config-files.js +7 -7
  73. package/dist/shared/jenkins/jenkins.service.d.ts.map +1 -1
  74. package/dist/shared/jenkins/jenkins.service.js +13 -8
  75. package/dist/shared/jenkins/jenkins.service.js.map +1 -1
  76. package/dist/shared/jenkins/role-strategy-groovy.js +84 -84
  77. package/docs/ARCHITECTURE.md +29 -29
  78. package/docs/CREATE-DEPLOYMENT-reference.groovy +1105 -1105
  79. package/package.json +9 -5
  80. package/templates/jenkins/CREATE-DEPLOYMENT.groovy +1105 -1105
  81. package/templates/jenkins/Jenkinsfile-Mobile-App +759 -759
  82. package/cloud-api/dist/index.js.map +0 -1
@@ -1,1105 +1,1105 @@
1
- // CREATE-DEPLOYMENT FINAL v9
2
- // Compatible dynamic parameter configuration: native Jenkins base parameters + Active Choices reactive references.
3
- // Requires the Jenkins Active Choices plugin (plugin ID: uno-choice).
4
- // FRONTEND: container port is forced to 80; backend-only settings are ignored.
5
- // BACKEND: container port, health endpoint, networks, volumes and flags are available.
6
- // HOST_PORT: live UI advisory check + authoritative build/deployment checks.
7
- //
8
- // IMPORTANT: after replacing the Jenkinsfile, run CREATE-DEPLOYMENT once to persist the new parameter definitions.
9
- // The properties() call below updates the job's Build with Parameters form.
10
-
11
- properties([
12
- parameters([
13
- choice(
14
- name: 'APP_TYPE',
15
- choices: ['SELECT', 'FRONTEND', 'BACKEND'],
16
- description: 'STEP 1 — Choose application type. FRONTEND is fixed to container port 80 and backend-only settings are ignored.'
17
- ),
18
- string(
19
- name: 'IMAGE_NAME',
20
- defaultValue: '',
21
- description: 'GHCR package name and generated Jenkins job name. Example: wholesum-order-service',
22
- trim: true
23
- ),
24
- string(
25
- name: 'GHCR_IMAGE',
26
- defaultValue: '',
27
- description: 'Full container image repository, including registry and owner. Example: ghcr.io/acme/order-service',
28
- trim: true
29
- ),
30
- string(
31
- name: 'GHCR_CREDENTIAL_ID',
32
- defaultValue: '',
33
- description: 'Jenkins username/password credential ID created/synchronized by IndraQ Cloud for pulling this project image.',
34
- trim: true
35
- ),
36
- string(
37
- name: 'INDRAQ_USERNAME',
38
- defaultValue: '',
39
- description: 'Jenkins username that should own/access the generated deployment job. Supplied automatically by the IndraQ CLI.',
40
- trim: true
41
- ),
42
- string(
43
- name: 'CONTAINER_NAME',
44
- defaultValue: '',
45
- description: 'Docker container name.',
46
- trim: true
47
- ),
48
- string(
49
- name: 'HOST_PORT',
50
- defaultValue: '',
51
- description: 'Host port published by Docker. Availability is shown below and verified again during build/deployment.',
52
- trim: true
53
- ),
54
- [
55
- $class: 'DynamicReferenceParameter',
56
- name: 'PORT_STATUS',
57
- description: 'Live advisory host-port check. Build-time validation remains authoritative.',
58
- choiceType: 'ET_FORMATTED_HTML',
59
- omitValueField: true,
60
- referencedParameters: 'HOST_PORT,CONTAINER_NAME',
61
- randomName: 'dynamic-reference-port-status-v10',
62
- script: [
63
- $class: 'GroovyScript',
64
- fallbackScript: [
65
- classpath: [],
66
- sandbox: true,
67
- script: '''return "<b style='color:#b45309'>PORT CHECK SCRIPT IS BLOCKED.</b> Approve the fixed Active Choices script once in <b>Manage Jenkins → In-process Script Approval</b>, then reload this page."'''
68
- ],
69
- script: [
70
- classpath: [],
71
- sandbox: false,
72
- script: '''
73
- import java.util.concurrent.TimeUnit
74
-
75
- def esc = { Object raw ->
76
- (raw == null ? '' : raw.toString())
77
- .replace('&', '&amp;')
78
- .replace('<', '&lt;')
79
- .replace('>', '&gt;')
80
- .replace('"', '&quot;')
81
- .replace("'", '&#39;')
82
- }
83
-
84
- def portText = HOST_PORT == null ? '' : HOST_PORT.toString().trim()
85
- def target = CONTAINER_NAME == null ? '' : CONTAINER_NAME.toString().trim()
86
-
87
- if (!portText) {
88
- return "<b>Enter HOST_PORT</b> to check availability."
89
- }
90
- if (!(portText ==~ /^\\d+$/)) {
91
- return "<b style='color:#b91c1c'>INVALID PORT:</b> HOST_PORT must contain digits only."
92
- }
93
-
94
- int port = portText.toInteger()
95
- if (port < 1 || port > 65535) {
96
- return "<b style='color:#b91c1c'>INVALID PORT:</b> Use a TCP port from 1 to 65535."
97
- }
98
- if (target && !(target ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
99
- target = ''
100
- }
101
-
102
- def firstExecutable = { List<String> candidates ->
103
- candidates.find { path ->
104
- try {
105
- new File(path).isFile() && new File(path).canExecute()
106
- } catch (Throwable ignored) {
107
- false
108
- }
109
- }
110
- }
111
-
112
- def runCommand = { List<String> command ->
113
- def process = new ProcessBuilder(command)
114
- .redirectErrorStream(true)
115
- .start()
116
-
117
- boolean completed = process.waitFor(4, TimeUnit.SECONDS)
118
- if (!completed) {
119
- process.destroyForcibly()
120
- return [code: 124, output: 'command timed out']
121
- }
122
-
123
- def output = process.inputStream.getText('UTF-8')
124
- return [code: process.exitValue(), output: output]
125
- }
126
-
127
- try {
128
- def dockerBin = firstExecutable([
129
- '/usr/bin/docker',
130
- '/usr/local/bin/docker',
131
- '/bin/docker'
132
- ])
133
-
134
- def ssBin = firstExecutable([
135
- '/usr/bin/ss',
136
- '/usr/sbin/ss',
137
- '/bin/ss',
138
- '/sbin/ss'
139
- ])
140
-
141
- if (!dockerBin) {
142
- return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> Docker CLI was not found on the Jenkins controller. Expected <code>/usr/bin/docker</code> or <code>/usr/local/bin/docker</code>."
143
- }
144
-
145
- def dockerResult = runCommand([
146
- dockerBin,
147
- 'ps',
148
- '--format',
149
- '{{.Names}}|{{.Ports}}'
150
- ])
151
-
152
- if (dockerResult.code != 0) {
153
- return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> Jenkins cannot query Docker as its OS user.<br><code>${esc(dockerResult.output.trim())}</code><br>Run over SSH: <code>sudo -u jenkins ${esc(dockerBin)} ps</code>"
154
- }
155
-
156
- def dockerLines = dockerResult.output.readLines()
157
-
158
- // Match Docker published TCP ports, e.g.
159
- // 0.0.0.0:6017-&gt;5000/tcp or [::]:6017-&gt;5000/tcp.
160
- def dockerOwnerFor = { int candidate ->
161
- def marker = ":${candidate}->"
162
- def line = dockerLines.find { row ->
163
- row.contains(marker) && row.contains('/tcp')
164
- }
165
- if (!line) return ''
166
- int separator = line.indexOf('|')
167
- separator >= 0 ? line.substring(0, separator).trim() : ''
168
- }
169
-
170
- // ss is used for non-Docker TCP listeners. Docker remains the
171
- // authoritative source for Docker-published ports because Docker
172
- // may reserve a port without a visible docker-proxy listener.
173
- def listenerPorts = [] as Set
174
- def ssWarning = ''
175
- if (ssBin) {
176
- def ssResult = runCommand([ssBin, '-H', '-ltn'])
177
- if (ssResult.code == 0) {
178
- ssResult.output.readLines().each { row ->
179
- def matcher = row =~ /(?:^|\\s)(?:\\[[^]]+\\]|[^\\s:]+|\\*):([0-9]+)(?:\\s|$)/
180
- matcher.each { full, p ->
181
- try { listenerPorts << p.toInteger() } catch (Throwable ignored) {}
182
- }
183
- }
184
- } else {
185
- ssWarning = " Host-listener check unavailable (${esc(ssResult.output.trim())})."
186
- }
187
- } else {
188
- ssWarning = ' The ss utility was not found, so only Docker-published ports were checked.'
189
- }
190
-
191
- def isBusy = { int candidate ->
192
- dockerOwnerFor(candidate) || listenerPorts.contains(candidate)
193
- }
194
-
195
- def suggestions = []
196
- int candidate = Math.max(1024, port + 1)
197
- int checked = 0
198
- while (candidate <= 65535 && suggestions.size() < 5 && checked < 250) {
199
- if (!isBusy(candidate)) suggestions << candidate
200
- candidate++
201
- checked++
202
- }
203
- def suggestionText = suggestions ? suggestions.join(', ') : 'No nearby free ports found.'
204
-
205
- def owner = dockerOwnerFor(port)
206
- if (owner) {
207
- if (target && owner == target) {
208
- return "<b style='color:#047857'>SAFE REDEPLOY:</b> Host port ${port} is currently mapped by the target container <code>${esc(owner)}</code>. The deployment pipeline will re-check it before restart.${ssWarning}"
209
- }
210
- return "<b style='color:#b91c1c'>PORT IN USE:</b> Host port ${port} is mapped by Docker container <code>${esc(owner)}</code>.<br><b>Suggested free ports:</b> ${esc(suggestionText)}${ssWarning}"
211
- }
212
-
213
- if (listenerPorts.contains(port)) {
214
- return "<b style='color:#b91c1c'>PORT IN USE:</b> Host port ${port} has a TCP listener outside the target Docker mapping.<br><b>Suggested free ports:</b> ${esc(suggestionText)}${ssWarning}"
215
- }
216
-
217
- return "<b style='color:#047857'>AVAILABLE:</b> Host port ${port} is not published by any running Docker container and has no TCP listener.${ssWarning}"
218
- } catch (Throwable t) {
219
- return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> ${esc(t.class.name)}: ${esc(t.message ?: 'No error message')}. Build-time validation will still protect the deployment."
220
- }
221
- '''
222
- ]
223
- ]
224
- ],
225
- [
226
- $class: 'DynamicReferenceParameter', name: 'CONTAINER_PORT',
227
- description: 'FRONTEND is fixed to 80. BACKEND can specify its internal application port.',
228
- choiceType: 'ET_FORMATTED_HTML', omitValueField: true,
229
- referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-container-port',
230
- script: [
231
- $class: 'GroovyScript',
232
- fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='text' class='jenkins-input'>"'''],
233
- script: [classpath: [], sandbox: true, script: '''
234
- if (APP_TYPE == 'FRONTEND') return "<input name='value' value='80' type='hidden'><b>80</b> — fixed automatically for frontend."
235
- if (APP_TYPE == 'BACKEND') return "<input name='value' value='5000' type='text' class='jenkins-input' placeholder='Example: 5000'>"
236
- return "<input name='value' value='' type='hidden'>Select APP_TYPE first."
237
- ''']
238
- ]
239
- ],
240
- [
241
- $class: 'DynamicReferenceParameter', name: 'HEALTH_ENDPOINT', description: 'Backend-only health endpoint.',
242
- choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-health-endpoint',
243
- script: [$class: 'GroovyScript',
244
- fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
245
- script: [classpath: [], sandbox: true, script: '''
246
- if (APP_TYPE == 'BACKEND') return "<input name='value' value='/health' type='text' class='jenkins-input' placeholder='/health'>"
247
- return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
248
- ''']]
249
- ],
250
- [
251
- $class: 'DynamicReferenceParameter', name: 'DOCKER_NETWORKS', description: 'Backend-only additional Docker networks, one per line. bridge is attached automatically.',
252
- choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-networks',
253
- script: [$class: 'GroovyScript',
254
- fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
255
- script: [classpath: [], sandbox: true, script: '''
256
- if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='One network per line'></textarea>"
257
- return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
258
- ''']]
259
- ],
260
- [
261
- $class: 'DynamicReferenceParameter', name: 'DOCKER_VOLUMES', description: 'Backend-only Docker volume specifications, one per line.',
262
- choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-volumes',
263
- script: [$class: 'GroovyScript',
264
- fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
265
- script: [classpath: [], sandbox: true, script: '''
266
- if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='Example: app-data:/app/data'></textarea>"
267
- return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
268
- ''']]
269
- ],
270
- [
271
- $class: 'DynamicReferenceParameter', name: 'DOCKER_FLAGS', description: 'Backend-only advanced docker run flags, one logical flag/group per line.',
272
- choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-flags',
273
- script: [$class: 'GroovyScript',
274
- fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
275
- script: [classpath: [], sandbox: true, script: '''
276
- if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='Advanced use only'></textarea>"
277
- return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
278
- ''']]
279
- ]
280
- ])
281
- ])
282
-
283
- pipeline {
284
- agent any
285
-
286
- options {
287
- disableConcurrentBuilds()
288
- }
289
-
290
- stages {
291
- stage('Validate Input') {
292
- steps {
293
- script {
294
- def imageName = params.IMAGE_NAME?.trim()
295
- def containerName = params.CONTAINER_NAME?.trim()
296
- def hostPort = params.HOST_PORT?.trim()
297
- def appType = params.APP_TYPE?.trim()
298
- def containerPort = appType == 'FRONTEND' ? '80' : params.CONTAINER_PORT?.trim()
299
- def healthEndpoint = appType == 'BACKEND' ? params.HEALTH_ENDPOINT?.trim() : ''
300
-
301
- if (!imageName) {
302
- error 'IMAGE_NAME is required.'
303
- }
304
-
305
- if (!(imageName ==~ /^[a-z0-9][a-z0-9._-]*$/)) {
306
- error 'IMAGE_NAME may contain only lowercase letters, numbers, dots, hyphens and underscores, and must start with a lowercase letter or number.'
307
- }
308
-
309
- def ghcrImage = params.GHCR_IMAGE?.trim()
310
- if (!ghcrImage) {
311
- error 'GHCR_IMAGE is required.'
312
- }
313
- if (!(ghcrImage ==~ /^[A-Za-z0-9.-]+(?::[0-9]+)?\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/)) {
314
- error 'GHCR_IMAGE must be a full registry/owner/image path.'
315
- }
316
-
317
- def ghcrCredentialId = params.GHCR_CREDENTIAL_ID?.trim()
318
- if (!ghcrCredentialId) {
319
- error 'GHCR_CREDENTIAL_ID is required.'
320
- }
321
- if (!(ghcrCredentialId ==~ /^[A-Za-z0-9_.-]+$/)) {
322
- error 'GHCR_CREDENTIAL_ID contains invalid characters.'
323
- }
324
-
325
- if (!containerName) {
326
- error 'CONTAINER_NAME is required.'
327
- }
328
-
329
- if (!(containerName ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
330
- error 'CONTAINER_NAME contains invalid characters.'
331
- }
332
-
333
- if (!hostPort || !(hostPort ==~ /^\d+$/)) {
334
- error 'HOST_PORT must be a number.'
335
- }
336
-
337
- if (!containerPort || !(containerPort ==~ /^\d+$/)) {
338
- error 'CONTAINER_PORT must be a number.'
339
- }
340
-
341
- int hostPortNumber = hostPort.toInteger()
342
- int containerPortNumber = containerPort.toInteger()
343
-
344
- if (hostPortNumber < 1 || hostPortNumber > 65535) {
345
- error 'HOST_PORT must be between 1 and 65535.'
346
- }
347
-
348
- if (containerPortNumber < 1 || containerPortNumber > 65535) {
349
- error 'CONTAINER_PORT must be between 1 and 65535.'
350
- }
351
-
352
- if (!['BACKEND', 'FRONTEND'].contains(appType)) {
353
- error 'Select APP_TYPE as FRONTEND or BACKEND before building.'
354
- }
355
-
356
- if (appType == 'BACKEND') {
357
- if (!healthEndpoint) {
358
- error 'HEALTH_ENDPOINT is required for BACKEND.'
359
- }
360
-
361
- if (!healthEndpoint.startsWith('/')) {
362
- healthEndpoint = "/${healthEndpoint}"
363
- }
364
-
365
- if (!(healthEndpoint ==~ '^/[A-Za-z0-9._~!$&()*+,;=:@%/?-]*$')) {
366
- error 'HEALTH_ENDPOINT contains unsupported characters.'
367
- }
368
- }
369
-
370
- def networks = appType == 'BACKEND' ? (params.DOCKER_NETWORKS
371
- ?.split('\n')
372
- ?.collect { it.trim() }
373
- ?.findAll { it }
374
- ?.unique() ?: []) : []
375
-
376
- networks.each { network ->
377
- if (!(network ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
378
- error "Invalid Docker network name: ${network}"
379
- }
380
- }
381
-
382
- def volumes = appType == 'BACKEND' ? (params.DOCKER_VOLUMES
383
- ?.split('\n')
384
- ?.collect { it.trim() }
385
- ?.findAll { it } ?: []) : []
386
-
387
- volumes.each { volume ->
388
- if (volume.contains('\n') || volume.contains('\r')) {
389
- error 'Docker volume entries must be one line each.'
390
- }
391
- if (volume.contains(';') || volume.contains('&') || volume.contains('|') || volume.contains('`') || volume.contains('$(')) {
392
- error "Unsafe shell operator found in Docker volume entry: ${volume}"
393
- }
394
- }
395
-
396
- def flags = appType == 'BACKEND' ? (params.DOCKER_FLAGS
397
- ?.split('\n')
398
- ?.collect { it.trim() }
399
- ?.findAll { it } ?: []) : []
400
-
401
- flags.each { flag ->
402
- if (flag.contains(';') || flag.contains('&') || flag.contains('|') || flag.contains('`') || flag.contains('$(')) {
403
- error "Unsafe shell operator found in DOCKER_FLAGS: ${flag}"
404
- }
405
- }
406
-
407
- echo 'Input validation successful.'
408
- }
409
- }
410
- }
411
-
412
-
413
- stage('Check Host Port') {
414
- steps {
415
- script {
416
- def hostPort = params.HOST_PORT.trim()
417
- def containerName = params.CONTAINER_NAME.trim()
418
-
419
- def portCheckResult = withEnv([
420
- "CHECK_HOST_PORT=${hostPort}",
421
- "CHECK_CONTAINER_NAME=${containerName}"
422
- ]) {
423
- sh(
424
- script: '''
425
- set -eu
426
-
427
- PORT="$CHECK_HOST_PORT"
428
- TARGET="$CHECK_CONTAINER_NAME"
429
-
430
- if ! command -v docker >/dev/null 2>&1; then
431
- echo "CHECK_ERROR|Docker CLI is not installed on this Jenkins node.|"
432
- exit 0
433
- fi
434
-
435
- if ! docker ps >/dev/null 2>&1; then
436
- echo "CHECK_ERROR|Jenkins cannot access the Docker daemon on this node.|"
437
- exit 0
438
- fi
439
-
440
- if ! command -v ss >/dev/null 2>&1; then
441
- echo "CHECK_ERROR|The ss command is required for host-port validation. Install the iproute2 package on the deployment host.|"
442
- exit 0
443
- fi
444
-
445
- port_in_use() {
446
- candidate="$1"
447
-
448
- if docker ps --format '{{.Ports}}' 2>/dev/null | grep -Eq ":${candidate}->[0-9]+/tcp"; then
449
- return 0
450
- fi
451
-
452
- if ss -H -ltn 2>/dev/null | awk -v p=":${candidate}" '$4 ~ (p "$") { found=1 } END { exit(found ? 0 : 1) }'; then
453
- return 0
454
- fi
455
-
456
- return 1
457
- }
458
-
459
- suggest_free_ports() {
460
- candidate=$((PORT + 1))
461
- checked=0
462
- found=0
463
- result=""
464
-
465
- while [ "$candidate" -le 65535 ] && [ "$checked" -lt 100 ] && [ "$found" -lt 5 ]; do
466
- if ! port_in_use "$candidate"; then
467
- if [ -n "$result" ]; then
468
- result="$result, $candidate"
469
- else
470
- result="$candidate"
471
- fi
472
- found=$((found + 1))
473
- fi
474
-
475
- candidate=$((candidate + 1))
476
- checked=$((checked + 1))
477
- done
478
-
479
- printf '%s' "$result"
480
- }
481
-
482
- docker_owner="$(
483
- docker ps --format '{{.Names}}|{{.Ports}}' 2>/dev/null |
484
- awk -F'|' -v p="$PORT" '$2 ~ (":" p "->[0-9]+/tcp") { print $1; exit }'
485
- )"
486
-
487
- if [ -n "$docker_owner" ]; then
488
- if [ "$docker_owner" = "$TARGET" ]; then
489
- echo "OK_TARGET|$docker_owner|"
490
- else
491
- suggestions="$(suggest_free_ports)"
492
- echo "BUSY_DOCKER|$docker_owner|$suggestions"
493
- fi
494
- exit 0
495
- fi
496
-
497
- listener="$(
498
- ss -H -ltnp 2>/dev/null |
499
- awk -v p=":${PORT}" '$4 ~ (p "$") { print; exit }'
500
- )"
501
-
502
- if [ -n "$listener" ]; then
503
- suggestions="$(suggest_free_ports)"
504
- echo "BUSY_PROCESS|$listener|$suggestions"
505
- exit 0
506
- fi
507
-
508
- echo "FREE||"
509
- ''',
510
- returnStdout: true
511
- ).trim()
512
- }
513
-
514
- def resultParts = portCheckResult.split('\\|', -1)
515
- def status = resultParts.length > 0 ? resultParts[0] : 'CHECK_ERROR'
516
- def detail = resultParts.length > 1 ? resultParts[1] : ''
517
- def suggestions = resultParts.length > 2 ? resultParts[2] : ''
518
-
519
- if (status == 'FREE') {
520
- echo "HOST_PORT ${hostPort} is available on the deployment host."
521
- } else if (status == 'OK_TARGET') {
522
- echo "HOST_PORT ${hostPort} is currently owned by the existing target container '${detail}'. This is safe for an update/redeploy."
523
- } else if (status == 'BUSY_DOCKER') {
524
- error "HOST_PORT ${hostPort} is already in use by Docker container '${detail}'.\n\nSuggested free ports: ${suggestions ?: 'No nearby free ports found automatically.'}\n\nChoose a different HOST_PORT and run CREATE-DEPLOYMENT again."
525
- } else if (status == 'BUSY_PROCESS') {
526
- error "HOST_PORT ${hostPort} is already in use by a host process.\n\nListener: ${detail}\nSuggested free ports: ${suggestions ?: 'No nearby free ports found automatically.'}\n\nChoose a different HOST_PORT and run CREATE-DEPLOYMENT again."
527
- } else {
528
- error "Host-port validation could not run safely: ${detail ?: portCheckResult}"
529
- }
530
- }
531
- }
532
- }
533
-
534
- stage('Build Deployment Template') {
535
- steps {
536
- script {
537
- def jobName = params.IMAGE_NAME.trim()
538
- def imageName = params.GHCR_IMAGE?.trim()
539
- def ghcrCredentialId = params.GHCR_CREDENTIAL_ID?.trim()
540
- def containerName = params.CONTAINER_NAME.trim()
541
- def hostPort = params.HOST_PORT.trim()
542
- def appType = params.APP_TYPE.trim()
543
- def containerPort = appType == 'FRONTEND' ? '80' : params.CONTAINER_PORT.trim()
544
-
545
- def healthEndpoint = params.HEALTH_ENDPOINT?.trim() ?: '/health'
546
- if (!healthEndpoint.startsWith('/')) {
547
- healthEndpoint = "/${healthEndpoint}"
548
- }
549
-
550
- def networks = []
551
- if (appType == 'BACKEND') {
552
- networks = params.DOCKER_NETWORKS
553
- ?.split('\n')
554
- ?.collect { it.trim() }
555
- ?.findAll { it && it != 'bridge' }
556
- ?.unique() ?: []
557
- }
558
-
559
- def volumes = []
560
- if (appType == 'BACKEND') {
561
- volumes = params.DOCKER_VOLUMES
562
- ?.split('\n')
563
- ?.collect { it.trim() }
564
- ?.findAll { it } ?: []
565
- }
566
-
567
- def flags = []
568
- if (appType == 'BACKEND') {
569
- flags = params.DOCKER_FLAGS
570
- ?.split('\n')
571
- ?.collect { it.trim() }
572
- ?.findAll { it } ?: []
573
- }
574
-
575
- // Escape arbitrary command text before placing it inside a
576
- // single-quoted Groovy string in the generated Jenkinsfile.
577
- def groovySingleQuoteContent = { String value ->
578
- value
579
- .replace('\\', '\\\\')
580
- .replace("'", "\\'")
581
- }
582
-
583
- def shellSingleQuote = { String value ->
584
- return "'" + value.replace("'", "'\"'\"'") + "'"
585
- }
586
-
587
- def networkSetupCommand = 'true'
588
- if (networks) {
589
- networkSetupCommand = networks.collect { network ->
590
- "docker network inspect ${network} >/dev/null 2>&1 || docker network create ${network}"
591
- }.join(' ; ')
592
- }
593
-
594
- def additionalNetworkCommand = 'true'
595
- if (networks) {
596
- additionalNetworkCommand = networks.collect { network ->
597
- "docker network connect ${network} \"\$CONTAINER_NAME\" 2>/dev/null || true"
598
- }.join(' ; ')
599
- }
600
-
601
- def runParts = [
602
- 'docker run -d',
603
- '--name "$CONTAINER_NAME"',
604
- '--restart unless-stopped',
605
- '-p "$HOST_PORT:$CONTAINER_PORT"',
606
- '--network bridge'
607
- ]
608
-
609
- volumes.each { volume ->
610
- runParts << "-v ${shellSingleQuote(volume)}"
611
- }
612
-
613
- flags.each { flag ->
614
- runParts << flag
615
- }
616
-
617
- if (appType == 'BACKEND') {
618
- runParts << "--health-cmd=\"curl -fsS http://localhost:\$CONTAINER_PORT${healthEndpoint} || exit 1\""
619
- runParts << '--health-interval=30s'
620
- runParts << '--health-timeout=5s'
621
- runParts << '--health-retries=3'
622
- runParts << '--health-start-period=20s'
623
- }
624
-
625
- runParts << '"$DEPLOY_IMAGE_REF"'
626
- def dockerRunCommand = runParts.join(' ')
627
-
628
- def loginCommand = 'REGISTRY="${IMAGE_NAME%%/*}"; echo "$GH_TOKEN" | docker login "$REGISTRY" -u "$GH_USER" --password-stdin'
629
- def pullCommand = 'docker pull "$DEPLOY_IMAGE_REF"'
630
- def stopCommand = 'docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true; docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true'
631
- def portPrecheckCommand = 'set -eu; if ! command -v docker >/dev/null 2>&1; then echo \"ERROR: Docker CLI is missing on this Jenkins node.\"; exit 1; fi; if ! docker ps >/dev/null 2>&1; then echo \"ERROR: Jenkins cannot access the Docker daemon on this node.\"; exit 1; fi; if ! command -v ss >/dev/null 2>&1; then echo \"ERROR: ss is required for host-port validation (install iproute2).\"; exit 1; fi; owner=\"$(docker ps --format \'{{.Names}}|{{.Ports}}\' 2>/dev/null | awk -F\'|\' -v p=\"$HOST_PORT\" \'$2 ~ (\":\" p \"->[0-9]+/tcp\") { print $1; exit }\')\"; if [ -n \"$owner\" ] && [ \"$owner\" != \"$CONTAINER_NAME\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is already used by Docker container $owner.\"; exit 1; fi; if [ -z \"$owner\" ]; then listener=\"$(ss -H -ltnp 2>/dev/null | awk -v p=\":$HOST_PORT\" \'$4 ~ (p \"$\") { print; exit }\')\"; if [ -n \"$listener\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is already used by a host process: $listener\"; exit 1; fi; fi; if [ \"$owner\" = \"$CONTAINER_NAME\" ]; then echo \"HOST_PORT $HOST_PORT is currently owned by the target container $CONTAINER_NAME; redeploy is allowed.\"; else echo \"HOST_PORT $HOST_PORT is available.\"; fi'
632
- def portReleasedCommand = 'set -eu; owner=\"$(docker ps --format \'{{.Names}}|{{.Ports}}\' 2>/dev/null | awk -F\'|\' -v p=\"$HOST_PORT\" \'$2 ~ (\":\" p \"->[0-9]+/tcp\") { print $1; exit }\')\"; if [ -n \"$owner\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT became occupied by Docker container $owner before docker run.\"; exit 1; fi; listener=\"$(ss -H -ltnp 2>/dev/null | awk -v p=\":$HOST_PORT\" \'$4 ~ (p \"$\") { print; exit }\')\"; if [ -n \"$listener\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is still occupied after stopping the old container: $listener\"; exit 1; fi; echo \"HOST_PORT $HOST_PORT is free immediately before docker run.\"'
633
- def frontendCheckCommand = 'docker inspect "$CONTAINER_NAME" >/dev/null && docker ps --filter "name=^${CONTAINER_NAME}$" --filter "status=running" --format "{{.Names}}" | grep -Fx "$CONTAINER_NAME"'
634
- def cleanupContainersCommand = 'docker container prune -f'
635
- def cleanupImagesCommand = 'docker image prune -af --filter "until=168h"'
636
- def cleanupBuilderCommand = 'docker builder prune -af --filter "until=168h"'
637
-
638
- def deploymentPipeline = '''
639
- pipeline {
640
- agent any
641
-
642
- options {
643
- quietPeriod(10)
644
- disableConcurrentBuilds()
645
- }
646
-
647
- parameters {
648
- choice(
649
- name: 'ACTION',
650
- choices: ['DEPLOY', 'ROLLBACK'],
651
- description: 'Deploy a new image or rollback to another image tag.'
652
- )
653
- string(
654
- name: 'IMAGE_TAG',
655
- defaultValue: '',
656
- description: 'Docker image tag for manual deploy/rollback.',
657
- trim: true
658
- )
659
- string(
660
- name: 'IMAGE_DIGEST',
661
- defaultValue: '',
662
- description: 'Optional sha256 image digest. If supplied, it is used instead of the mutable tag.',
663
- trim: true
664
- )
665
- string(
666
- name: 'DEPLOYMENT_SOURCE',
667
- defaultValue: 'MANUAL',
668
- description: 'Deployment source.',
669
- trim: true
670
- )
671
- }
672
-
673
- triggers {
674
- GenericTrigger(
675
- genericVariables: [
676
- [key: 'PACKAGE_NAME', value: '$.package.name'],
677
- [key: 'PACKAGE_TAG', value: '$.package.package_version.container_metadata.tag.name'],
678
- [key: 'PACKAGE_DIGEST', value: '$.package.package_version.container_metadata.tag.digest'],
679
- [key: 'EVENT_ACTION', value: '$.action']
680
- ],
681
- causeString: 'GHCR: $PACKAGE_NAME:$PACKAGE_TAG',
682
- token: 'wholesum-order-webhook',
683
- printContributedVariables: true,
684
- printPostContent: false,
685
- silentResponse: false,
686
- allowSeveralTriggersPerBuild: false,
687
- regexpFilterText: '$EVENT_ACTION:$PACKAGE_NAME:$PACKAGE_TAG',
688
- regexpFilterExpression: '^published:__PACKAGE_NAME_REGEX__:.+$'
689
- )
690
- }
691
-
692
- environment {
693
- IMAGE_NAME = '__IMAGE_NAME__'
694
- CONTAINER_NAME = '__CONTAINER_NAME__'
695
- HOST_PORT = '__HOST_PORT__'
696
- CONTAINER_PORT = '__CONTAINER_PORT__'
697
- }
698
-
699
- stages {
700
- stage('Resolve Deployment') {
701
- steps {
702
- script {
703
- if (env.PACKAGE_TAG?.trim()) {
704
- env.DEPLOY_TAG = env.PACKAGE_TAG.trim()
705
- env.DEPLOY_SOURCE = 'GITHUB_WEBHOOK'
706
- env.DEPLOY_ACTION = 'DEPLOY'
707
- echo 'GHCR webhook deployment detected.'
708
- } else {
709
- if (!params.IMAGE_TAG?.trim()) {
710
- error 'IMAGE_TAG is required for manual deployment.'
711
- }
712
-
713
- env.DEPLOY_TAG = params.IMAGE_TAG.trim()
714
- env.DEPLOY_SOURCE = params.DEPLOYMENT_SOURCE?.trim() ?: 'MANUAL'
715
- env.DEPLOY_ACTION = params.ACTION ?: 'DEPLOY'
716
- echo 'Manual deployment detected.'
717
- }
718
-
719
- if (!(env.DEPLOY_TAG ==~ /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/)) {
720
- error "Invalid Docker image tag: ${env.DEPLOY_TAG}"
721
- }
722
-
723
- def requestedDigest = env.PACKAGE_DIGEST?.trim()
724
- if (!requestedDigest) {
725
- requestedDigest = params.IMAGE_DIGEST?.trim()
726
- }
727
-
728
- if (requestedDigest) {
729
- if (!(requestedDigest ==~ /^sha256:[a-fA-F0-9]{64}$/)) {
730
- error "Invalid image digest: ${requestedDigest}"
731
- }
732
- env.DEPLOY_DIGEST = requestedDigest
733
- env.DEPLOY_IMAGE_REF = "${env.IMAGE_NAME}@${requestedDigest}"
734
- } else {
735
- env.DEPLOY_DIGEST = ''
736
- env.DEPLOY_IMAGE_REF = "${env.IMAGE_NAME}:${env.DEPLOY_TAG}"
737
- }
738
-
739
- echo ''
740
- echo '=============================================='
741
- echo ' DEPLOYMENT START'
742
- echo '=============================================='
743
- echo ''
744
- echo 'Service : __JOB_NAME__'
745
- echo "Image : ${env.IMAGE_NAME}"
746
- echo "Tag : ${env.DEPLOY_TAG}"
747
- if (env.DEPLOY_DIGEST) {
748
- echo "Digest : ${env.DEPLOY_DIGEST}"
749
- }
750
- echo "Container : ${env.CONTAINER_NAME}"
751
- echo "Action : ${env.DEPLOY_ACTION}"
752
- echo "Source : ${env.DEPLOY_SOURCE}"
753
- echo ''
754
- }
755
- }
756
- }
757
-
758
-
759
- stage('Validate Host Port') {
760
- steps {
761
- sh '__PORT_PRECHECK_COMMAND__'
762
- }
763
- }
764
-
765
- stage('Login to GHCR') {
766
- steps {
767
- withCredentials([
768
- usernamePassword(
769
- credentialsId: '__GHCR_CREDENTIAL_ID__',
770
- usernameVariable: 'GH_USER',
771
- passwordVariable: 'GH_TOKEN'
772
- )
773
- ]) {
774
- sh '__LOGIN_COMMAND__'
775
- }
776
- }
777
- }
778
-
779
- stage('Pull Image') {
780
- steps {
781
- sh '__PULL_COMMAND__'
782
- }
783
- }
784
-
785
- stage('Prepare Docker Networks') {
786
- when {
787
- expression {
788
- return '__APP_TYPE__' == 'BACKEND'
789
- }
790
- }
791
- steps {
792
- sh '__NETWORK_SETUP_COMMAND__'
793
- }
794
- }
795
-
796
- stage('Stop Existing Container') {
797
- steps {
798
- sh '__STOP_COMMAND__'
799
- }
800
- }
801
-
802
-
803
- stage('Confirm Host Port Released') {
804
- steps {
805
- sh '__PORT_RELEASED_COMMAND__'
806
- }
807
- }
808
-
809
- stage('Run New Container') {
810
- steps {
811
- sh '__RUN_COMMAND__'
812
- }
813
- }
814
-
815
- stage('Connect Additional Networks') {
816
- when {
817
- expression {
818
- return '__APP_TYPE__' == 'BACKEND'
819
- }
820
- }
821
- steps {
822
- sh '__CONNECT_NETWORK_COMMAND__'
823
- }
824
- }
825
-
826
- stage('Health Check') {
827
- when {
828
- expression {
829
- return '__APP_TYPE__' == 'BACKEND'
830
- }
831
- }
832
- steps {
833
- script {
834
- echo ''
835
- echo 'Waiting for Docker health check...'
836
- echo ''
837
-
838
- timeout(time: 2, unit: 'MINUTES') {
839
- waitUntil {
840
- def status = sh(
841
- script: 'docker inspect --format="{{.State.Health.Status}}" "$CONTAINER_NAME"',
842
- returnStdout: true
843
- ).trim()
844
-
845
- echo "Container health: ${status}"
846
-
847
- if (status == 'healthy') {
848
- echo 'Health check PASSED.'
849
- return true
850
- }
851
-
852
- if (status == 'unhealthy') {
853
- sh 'docker logs --tail 200 "$CONTAINER_NAME" || true'
854
- error 'Container health check FAILED.'
855
- }
856
-
857
- sleep 5
858
- return false
859
- }
860
- }
861
- }
862
- }
863
- }
864
-
865
- stage('Frontend Running Check') {
866
- when {
867
- expression {
868
- return '__APP_TYPE__' == 'FRONTEND'
869
- }
870
- }
871
- steps {
872
- sh '__FRONTEND_CHECK_COMMAND__'
873
- }
874
- }
875
-
876
- stage('Docker Cleanup') {
877
- steps {
878
- script {
879
- echo ''
880
- echo '=============================================='
881
- echo ' DOCKER CLEANUP'
882
- echo '=============================================='
883
- echo ''
884
-
885
- echo 'Removing stopped containers...'
886
- sh '__CLEANUP_CONTAINERS_COMMAND__'
887
-
888
- echo 'Removing unused images older than 7 days...'
889
- sh '__CLEANUP_IMAGES_COMMAND__'
890
-
891
- echo 'Removing unused build cache older than 7 days...'
892
- sh '__CLEANUP_BUILDER_COMMAND__'
893
-
894
- echo ''
895
- echo 'Docker cleanup completed. Volumes were not pruned.'
896
- echo ''
897
- }
898
- }
899
- }
900
- }
901
-
902
- post {
903
- success {
904
- slackSend(
905
- channel: '#jenkins-deployment',
906
- color: 'good',
907
- message: """
908
- 🟢 *DEPLOYMENT SUCCESSFUL*
909
-
910
- *Service:* __JOB_NAME__
911
- *Image:* ${env.IMAGE_NAME}
912
- *Tag:* ${env.DEPLOY_TAG}
913
- *Container:* ${env.CONTAINER_NAME}
914
- *Action:* ${env.DEPLOY_ACTION}
915
- *Source:* ${env.DEPLOY_SOURCE}
916
- *Build:* #${env.BUILD_NUMBER}
917
-
918
- Deployment completed successfully.
919
- <${env.BUILD_URL}|View Jenkins Build>
920
- """.stripIndent()
921
- )
922
- }
923
-
924
- failure {
925
- script {
926
- sh 'docker logs --tail 200 "$CONTAINER_NAME" || true'
927
- }
928
- slackSend(
929
- channel: '#jenkins-deployment',
930
- color: 'danger',
931
- message: """
932
- 🔴 *DEPLOYMENT FAILED*
933
-
934
- *Service:* __JOB_NAME__
935
- *Image:* ${env.IMAGE_NAME}
936
- *Tag:* ${env.DEPLOY_TAG ?: 'UNKNOWN'}
937
- *Container:* ${env.CONTAINER_NAME}
938
- *Action:* ${env.DEPLOY_ACTION ?: 'UNKNOWN'}
939
- *Source:* ${env.DEPLOY_SOURCE ?: 'UNKNOWN'}
940
- *Build:* #${env.BUILD_NUMBER}
941
-
942
- Deployment or health check failed.
943
- <${env.BUILD_URL}|View Jenkins Build>
944
- """.stripIndent()
945
- )
946
- }
947
- }
948
- }
949
- '''
950
-
951
- deploymentPipeline = deploymentPipeline
952
- .replace('__JOB_NAME__', jobName)
953
- .replace('__IMAGE_NAME__', imageName)
954
- .replace('__CONTAINER_NAME__', containerName)
955
- .replace('__HOST_PORT__', hostPort)
956
- .replace('__CONTAINER_PORT__', containerPort)
957
- .replace('__APP_TYPE__', appType)
958
- .replace('__GHCR_CREDENTIAL_ID__', ghcrCredentialId)
959
- .replace('__PACKAGE_NAME_REGEX__', jobName)
960
- .replace('__LOGIN_COMMAND__', groovySingleQuoteContent(loginCommand))
961
- .replace('__PULL_COMMAND__', groovySingleQuoteContent(pullCommand))
962
- .replace('__NETWORK_SETUP_COMMAND__', groovySingleQuoteContent(networkSetupCommand))
963
- .replace('__STOP_COMMAND__', groovySingleQuoteContent(stopCommand))
964
- .replace('__PORT_PRECHECK_COMMAND__', groovySingleQuoteContent(portPrecheckCommand))
965
- .replace('__PORT_RELEASED_COMMAND__', groovySingleQuoteContent(portReleasedCommand))
966
- .replace('__RUN_COMMAND__', groovySingleQuoteContent(dockerRunCommand))
967
- .replace('__CONNECT_NETWORK_COMMAND__', groovySingleQuoteContent(additionalNetworkCommand))
968
- .replace('__FRONTEND_CHECK_COMMAND__', groovySingleQuoteContent(frontendCheckCommand))
969
- .replace('__CLEANUP_CONTAINERS_COMMAND__', groovySingleQuoteContent(cleanupContainersCommand))
970
- .replace('__CLEANUP_IMAGES_COMMAND__', groovySingleQuoteContent(cleanupImagesCommand))
971
- .replace('__CLEANUP_BUILDER_COMMAND__', groovySingleQuoteContent(cleanupBuilderCommand))
972
-
973
- // The generated child Jenkinsfile deliberately contains no
974
- // triple-single-quoted strings. That makes it safe to embed
975
- // directly in Job DSL without prematurely closing this literal.
976
- if (deploymentPipeline.contains("'''")) {
977
- error 'Internal generator error: generated pipeline contains a forbidden triple-single-quote sequence.'
978
- }
979
-
980
- // Keep this Job DSL script CONSTANT. Jenkins Script Security
981
- // approves unsandboxed scripts by their exact content/hash.
982
- // Dynamic service values are supplied separately through
983
- // additionalParameters, so creating another deployment does not
984
- // produce a brand-new Job DSL script requiring another approval.
985
- def dslScript = '''
986
- pipelineJob(JOB_NAME) {
987
- description("""
988
- Automatically generated Docker deployment pipeline.
989
-
990
- Service: ${JOB_NAME}
991
- GHCR Image: ${GHCR_IMAGE}
992
- GHCR Credential: ${GHCR_CREDENTIAL_ID}
993
- Container: ${DOCKER_CONTAINER}
994
- Host Port: ${DOCKER_HOST_PORT}
995
- Container Port: ${DOCKER_CONTAINER_PORT}
996
- Application Type: ${APPLICATION_TYPE}
997
- Generated by: CREATE-DEPLOYMENT
998
- """.stripIndent())
999
-
1000
- logRotator {
1001
- numToKeep(50)
1002
- }
1003
-
1004
- definition {
1005
- cps {
1006
- script(PIPELINE_SCRIPT)
1007
- sandbox()
1008
- }
1009
- }
1010
- }
1011
- '''
1012
-
1013
- // The seed Job DSL is unsandboxed because this Jenkins instance
1014
- // currently executes CREATE-DEPLOYMENT as SYSTEM. Unlike v2,
1015
- // scriptText above never changes. Approve this constant DSL once;
1016
- // future service-specific values are only bound parameters.
1017
- // The generated child Pipeline itself remains sandboxed.
1018
- jobDsl(
1019
- scriptText: dslScript,
1020
- additionalParameters: [
1021
- JOB_NAME: jobName,
1022
- GHCR_IMAGE: imageName,
1023
- GHCR_CREDENTIAL_ID: ghcrCredentialId,
1024
- DOCKER_CONTAINER: containerName,
1025
- DOCKER_HOST_PORT: hostPort,
1026
- DOCKER_CONTAINER_PORT: containerPort,
1027
- APPLICATION_TYPE: appType,
1028
- PIPELINE_SCRIPT: deploymentPipeline
1029
- ],
1030
- sandbox: false,
1031
- removedJobAction: 'IGNORE',
1032
- removedViewAction: 'IGNORE',
1033
- lookupStrategy: 'JENKINS_ROOT'
1034
- )
1035
-
1036
- // Give the IndraQ user that requested this deployment access
1037
- // to the generated job without granting visibility/configure
1038
- // rights to unrelated Jenkins jobs. This is best-effort so a
1039
- // deployment is not destroyed merely because Role Strategy is
1040
- // not installed/configured yet; unified user preflight normally
1041
- // guarantees that it is available.
1042
- def indraqUsername = params.INDRAQ_USERNAME?.trim()
1043
- if (indraqUsername) {
1044
- try {
1045
- def strategy = jenkins.model.Jenkins.get().authorizationStrategy
1046
- if (strategy instanceof com.michelin.cio.hudson.plugins.rolestrategy.RoleBasedAuthorizationStrategy) {
1047
- def roleMap = strategy.getRoleMap(com.synopsys.arc.jenkins.plugins.rolestrategy.RoleType.Project)
1048
- def roleName = ('indraq-owner-' + indraqUsername + '-' + jobName).replaceAll('[^A-Za-z0-9_.-]', '-').take(120)
1049
- def oldRole = roleMap.getRole(roleName)
1050
- if (oldRole != null) roleMap.removeRole(oldRole)
1051
- Set<hudson.security.Permission> permissions = new HashSet<hudson.security.Permission>()
1052
- permissions.add(hudson.model.Item.DISCOVER)
1053
- permissions.add(hudson.model.Item.READ)
1054
- permissions.add(hudson.model.Item.BUILD)
1055
- permissions.add(hudson.model.Item.CANCEL)
1056
- permissions.add(hudson.model.Item.CONFIGURE)
1057
- permissions.add(hudson.model.Item.WORKSPACE)
1058
- def role = new com.michelin.cio.hudson.plugins.rolestrategy.Role(roleName, '^' + java.util.regex.Pattern.quote(jobName) + '$', permissions)
1059
- roleMap.addRole(role)
1060
- roleMap.assignRole(role, new com.michelin.cio.hudson.plugins.rolestrategy.PermissionEntry(com.michelin.cio.hudson.plugins.rolestrategy.AuthorizationType.USER, indraqUsername))
1061
- jenkins.model.Jenkins.get().save()
1062
- echo "Granted Jenkins item access to ${indraqUsername} for ${jobName}."
1063
- } else {
1064
- echo 'WARNING: Role Strategy is not active; generated job access was not assigned automatically.'
1065
- }
1066
- } catch (Throwable accessError) {
1067
- echo "WARNING: Generated job was created, but item access could not be assigned automatically: ${accessError.message}"
1068
- }
1069
- }
1070
- echo ''
1071
- echo '=============================================='
1072
- echo ' DEPLOYMENT JOB CREATED / UPDATED'
1073
- echo '=============================================='
1074
- echo ''
1075
- echo "Jenkins Job: ${jobName}"
1076
- echo "GHCR Image: ${imageName}"
1077
- echo "Container: ${containerName}"
1078
- echo "Application: ${appType}"
1079
- if (appType == 'FRONTEND') {
1080
- echo 'Frontend container port: 80 (FIXED)'
1081
- echo 'Frontend backend-only Docker settings: DISABLED'
1082
- }
1083
- echo "Host Port: ${hostPort}"
1084
- echo 'Host-port availability checks: ENABLED (creation + deployment)'
1085
- echo "Container Port: ${containerPort}"
1086
- echo 'Default Docker Network: bridge'
1087
-
1088
- if (appType == 'BACKEND') {
1089
- echo "Additional Networks: ${networks ? networks.join(', ') : 'None'}"
1090
- echo "Health Endpoint: ${healthEndpoint}"
1091
- }
1092
-
1093
- echo 'Automatic GHCR deployment: ENABLED'
1094
- echo 'Manual deployment: ENABLED'
1095
- echo 'Manual rollback: ENABLED'
1096
- echo 'Slack notifications: ENABLED'
1097
- echo 'Docker cleanup: ENABLED'
1098
- echo 'Image retention: 7 days'
1099
- echo 'Volume pruning: DISABLED'
1100
- echo ''
1101
- }
1102
- }
1103
- }
1104
- }
1105
- }
1
+ // CREATE-DEPLOYMENT FINAL v9
2
+ // Compatible dynamic parameter configuration: native Jenkins base parameters + Active Choices reactive references.
3
+ // Requires the Jenkins Active Choices plugin (plugin ID: uno-choice).
4
+ // FRONTEND: container port is forced to 80; backend-only settings are ignored.
5
+ // BACKEND: container port, health endpoint, networks, volumes and flags are available.
6
+ // HOST_PORT: live UI advisory check + authoritative build/deployment checks.
7
+ //
8
+ // IMPORTANT: after replacing the Jenkinsfile, run CREATE-DEPLOYMENT once to persist the new parameter definitions.
9
+ // The properties() call below updates the job's Build with Parameters form.
10
+
11
+ properties([
12
+ parameters([
13
+ choice(
14
+ name: 'APP_TYPE',
15
+ choices: ['SELECT', 'FRONTEND', 'BACKEND'],
16
+ description: 'STEP 1 — Choose application type. FRONTEND is fixed to container port 80 and backend-only settings are ignored.'
17
+ ),
18
+ string(
19
+ name: 'IMAGE_NAME',
20
+ defaultValue: '',
21
+ description: 'GHCR package name and generated Jenkins job name. Example: wholesum-order-service',
22
+ trim: true
23
+ ),
24
+ string(
25
+ name: 'GHCR_IMAGE',
26
+ defaultValue: '',
27
+ description: 'Full container image repository, including registry and owner. Example: ghcr.io/acme/order-service',
28
+ trim: true
29
+ ),
30
+ string(
31
+ name: 'GHCR_CREDENTIAL_ID',
32
+ defaultValue: '',
33
+ description: 'Jenkins username/password credential ID created/synchronized by IndraQ Cloud for pulling this project image.',
34
+ trim: true
35
+ ),
36
+ string(
37
+ name: 'INDRAQ_USERNAME',
38
+ defaultValue: '',
39
+ description: 'Jenkins username that should own/access the generated deployment job. Supplied automatically by the IndraQ CLI.',
40
+ trim: true
41
+ ),
42
+ string(
43
+ name: 'CONTAINER_NAME',
44
+ defaultValue: '',
45
+ description: 'Docker container name.',
46
+ trim: true
47
+ ),
48
+ string(
49
+ name: 'HOST_PORT',
50
+ defaultValue: '',
51
+ description: 'Host port published by Docker. Availability is shown below and verified again during build/deployment.',
52
+ trim: true
53
+ ),
54
+ [
55
+ $class: 'DynamicReferenceParameter',
56
+ name: 'PORT_STATUS',
57
+ description: 'Live advisory host-port check. Build-time validation remains authoritative.',
58
+ choiceType: 'ET_FORMATTED_HTML',
59
+ omitValueField: true,
60
+ referencedParameters: 'HOST_PORT,CONTAINER_NAME',
61
+ randomName: 'dynamic-reference-port-status-v10',
62
+ script: [
63
+ $class: 'GroovyScript',
64
+ fallbackScript: [
65
+ classpath: [],
66
+ sandbox: true,
67
+ script: '''return "<b style='color:#b45309'>PORT CHECK SCRIPT IS BLOCKED.</b> Approve the fixed Active Choices script once in <b>Manage Jenkins → In-process Script Approval</b>, then reload this page."'''
68
+ ],
69
+ script: [
70
+ classpath: [],
71
+ sandbox: false,
72
+ script: '''
73
+ import java.util.concurrent.TimeUnit
74
+
75
+ def esc = { Object raw ->
76
+ (raw == null ? '' : raw.toString())
77
+ .replace('&', '&amp;')
78
+ .replace('<', '&lt;')
79
+ .replace('>', '&gt;')
80
+ .replace('"', '&quot;')
81
+ .replace("'", '&#39;')
82
+ }
83
+
84
+ def portText = HOST_PORT == null ? '' : HOST_PORT.toString().trim()
85
+ def target = CONTAINER_NAME == null ? '' : CONTAINER_NAME.toString().trim()
86
+
87
+ if (!portText) {
88
+ return "<b>Enter HOST_PORT</b> to check availability."
89
+ }
90
+ if (!(portText ==~ /^\\d+$/)) {
91
+ return "<b style='color:#b91c1c'>INVALID PORT:</b> HOST_PORT must contain digits only."
92
+ }
93
+
94
+ int port = portText.toInteger()
95
+ if (port < 1 || port > 65535) {
96
+ return "<b style='color:#b91c1c'>INVALID PORT:</b> Use a TCP port from 1 to 65535."
97
+ }
98
+ if (target && !(target ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
99
+ target = ''
100
+ }
101
+
102
+ def firstExecutable = { List<String> candidates ->
103
+ candidates.find { path ->
104
+ try {
105
+ new File(path).isFile() && new File(path).canExecute()
106
+ } catch (Throwable ignored) {
107
+ false
108
+ }
109
+ }
110
+ }
111
+
112
+ def runCommand = { List<String> command ->
113
+ def process = new ProcessBuilder(command)
114
+ .redirectErrorStream(true)
115
+ .start()
116
+
117
+ boolean completed = process.waitFor(4, TimeUnit.SECONDS)
118
+ if (!completed) {
119
+ process.destroyForcibly()
120
+ return [code: 124, output: 'command timed out']
121
+ }
122
+
123
+ def output = process.inputStream.getText('UTF-8')
124
+ return [code: process.exitValue(), output: output]
125
+ }
126
+
127
+ try {
128
+ def dockerBin = firstExecutable([
129
+ '/usr/bin/docker',
130
+ '/usr/local/bin/docker',
131
+ '/bin/docker'
132
+ ])
133
+
134
+ def ssBin = firstExecutable([
135
+ '/usr/bin/ss',
136
+ '/usr/sbin/ss',
137
+ '/bin/ss',
138
+ '/sbin/ss'
139
+ ])
140
+
141
+ if (!dockerBin) {
142
+ return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> Docker CLI was not found on the Jenkins controller. Expected <code>/usr/bin/docker</code> or <code>/usr/local/bin/docker</code>."
143
+ }
144
+
145
+ def dockerResult = runCommand([
146
+ dockerBin,
147
+ 'ps',
148
+ '--format',
149
+ '{{.Names}}|{{.Ports}}'
150
+ ])
151
+
152
+ if (dockerResult.code != 0) {
153
+ return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> Jenkins cannot query Docker as its OS user.<br><code>${esc(dockerResult.output.trim())}</code><br>Run over SSH: <code>sudo -u jenkins ${esc(dockerBin)} ps</code>"
154
+ }
155
+
156
+ def dockerLines = dockerResult.output.readLines()
157
+
158
+ // Match Docker published TCP ports, e.g.
159
+ // 0.0.0.0:6017-&gt;5000/tcp or [::]:6017-&gt;5000/tcp.
160
+ def dockerOwnerFor = { int candidate ->
161
+ def marker = ":${candidate}->"
162
+ def line = dockerLines.find { row ->
163
+ row.contains(marker) && row.contains('/tcp')
164
+ }
165
+ if (!line) return ''
166
+ int separator = line.indexOf('|')
167
+ separator >= 0 ? line.substring(0, separator).trim() : ''
168
+ }
169
+
170
+ // ss is used for non-Docker TCP listeners. Docker remains the
171
+ // authoritative source for Docker-published ports because Docker
172
+ // may reserve a port without a visible docker-proxy listener.
173
+ def listenerPorts = [] as Set
174
+ def ssWarning = ''
175
+ if (ssBin) {
176
+ def ssResult = runCommand([ssBin, '-H', '-ltn'])
177
+ if (ssResult.code == 0) {
178
+ ssResult.output.readLines().each { row ->
179
+ def matcher = row =~ /(?:^|\\s)(?:\\[[^]]+\\]|[^\\s:]+|\\*):([0-9]+)(?:\\s|$)/
180
+ matcher.each { full, p ->
181
+ try { listenerPorts << p.toInteger() } catch (Throwable ignored) {}
182
+ }
183
+ }
184
+ } else {
185
+ ssWarning = " Host-listener check unavailable (${esc(ssResult.output.trim())})."
186
+ }
187
+ } else {
188
+ ssWarning = ' The ss utility was not found, so only Docker-published ports were checked.'
189
+ }
190
+
191
+ def isBusy = { int candidate ->
192
+ dockerOwnerFor(candidate) || listenerPorts.contains(candidate)
193
+ }
194
+
195
+ def suggestions = []
196
+ int candidate = Math.max(1024, port + 1)
197
+ int checked = 0
198
+ while (candidate <= 65535 && suggestions.size() < 5 && checked < 250) {
199
+ if (!isBusy(candidate)) suggestions << candidate
200
+ candidate++
201
+ checked++
202
+ }
203
+ def suggestionText = suggestions ? suggestions.join(', ') : 'No nearby free ports found.'
204
+
205
+ def owner = dockerOwnerFor(port)
206
+ if (owner) {
207
+ if (target && owner == target) {
208
+ return "<b style='color:#047857'>SAFE REDEPLOY:</b> Host port ${port} is currently mapped by the target container <code>${esc(owner)}</code>. The deployment pipeline will re-check it before restart.${ssWarning}"
209
+ }
210
+ return "<b style='color:#b91c1c'>PORT IN USE:</b> Host port ${port} is mapped by Docker container <code>${esc(owner)}</code>.<br><b>Suggested free ports:</b> ${esc(suggestionText)}${ssWarning}"
211
+ }
212
+
213
+ if (listenerPorts.contains(port)) {
214
+ return "<b style='color:#b91c1c'>PORT IN USE:</b> Host port ${port} has a TCP listener outside the target Docker mapping.<br><b>Suggested free ports:</b> ${esc(suggestionText)}${ssWarning}"
215
+ }
216
+
217
+ return "<b style='color:#047857'>AVAILABLE:</b> Host port ${port} is not published by any running Docker container and has no TCP listener.${ssWarning}"
218
+ } catch (Throwable t) {
219
+ return "<b style='color:#b91c1c'>PORT CHECK ERROR:</b> ${esc(t.class.name)}: ${esc(t.message ?: 'No error message')}. Build-time validation will still protect the deployment."
220
+ }
221
+ '''
222
+ ]
223
+ ]
224
+ ],
225
+ [
226
+ $class: 'DynamicReferenceParameter', name: 'CONTAINER_PORT',
227
+ description: 'FRONTEND is fixed to 80. BACKEND can specify its internal application port.',
228
+ choiceType: 'ET_FORMATTED_HTML', omitValueField: true,
229
+ referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-container-port',
230
+ script: [
231
+ $class: 'GroovyScript',
232
+ fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='text' class='jenkins-input'>"'''],
233
+ script: [classpath: [], sandbox: true, script: '''
234
+ if (APP_TYPE == 'FRONTEND') return "<input name='value' value='80' type='hidden'><b>80</b> — fixed automatically for frontend."
235
+ if (APP_TYPE == 'BACKEND') return "<input name='value' value='5000' type='text' class='jenkins-input' placeholder='Example: 5000'>"
236
+ return "<input name='value' value='' type='hidden'>Select APP_TYPE first."
237
+ ''']
238
+ ]
239
+ ],
240
+ [
241
+ $class: 'DynamicReferenceParameter', name: 'HEALTH_ENDPOINT', description: 'Backend-only health endpoint.',
242
+ choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-health-endpoint',
243
+ script: [$class: 'GroovyScript',
244
+ fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
245
+ script: [classpath: [], sandbox: true, script: '''
246
+ if (APP_TYPE == 'BACKEND') return "<input name='value' value='/health' type='text' class='jenkins-input' placeholder='/health'>"
247
+ return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
248
+ ''']]
249
+ ],
250
+ [
251
+ $class: 'DynamicReferenceParameter', name: 'DOCKER_NETWORKS', description: 'Backend-only additional Docker networks, one per line. bridge is attached automatically.',
252
+ choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-networks',
253
+ script: [$class: 'GroovyScript',
254
+ fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
255
+ script: [classpath: [], sandbox: true, script: '''
256
+ if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='One network per line'></textarea>"
257
+ return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
258
+ ''']]
259
+ ],
260
+ [
261
+ $class: 'DynamicReferenceParameter', name: 'DOCKER_VOLUMES', description: 'Backend-only Docker volume specifications, one per line.',
262
+ choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-volumes',
263
+ script: [$class: 'GroovyScript',
264
+ fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
265
+ script: [classpath: [], sandbox: true, script: '''
266
+ if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='Example: app-data:/app/data'></textarea>"
267
+ return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
268
+ ''']]
269
+ ],
270
+ [
271
+ $class: 'DynamicReferenceParameter', name: 'DOCKER_FLAGS', description: 'Backend-only advanced docker run flags, one logical flag/group per line.',
272
+ choiceType: 'ET_FORMATTED_HTML', omitValueField: true, referencedParameters: 'APP_TYPE', randomName: 'dynamic-reference-docker-flags',
273
+ script: [$class: 'GroovyScript',
274
+ fallbackScript: [classpath: [], sandbox: true, script: '''return "<input name='value' value='' type='hidden'>"'''],
275
+ script: [classpath: [], sandbox: true, script: '''
276
+ if (APP_TYPE == 'BACKEND') return "<textarea name='value' rows='3' class='jenkins-input' placeholder='Advanced use only'></textarea>"
277
+ return "<input name='value' value='' type='hidden'><span>Not used for frontend.</span>"
278
+ ''']]
279
+ ]
280
+ ])
281
+ ])
282
+
283
+ pipeline {
284
+ agent any
285
+
286
+ options {
287
+ disableConcurrentBuilds()
288
+ }
289
+
290
+ stages {
291
+ stage('Validate Input') {
292
+ steps {
293
+ script {
294
+ def imageName = params.IMAGE_NAME?.trim()
295
+ def containerName = params.CONTAINER_NAME?.trim()
296
+ def hostPort = params.HOST_PORT?.trim()
297
+ def appType = params.APP_TYPE?.trim()
298
+ def containerPort = appType == 'FRONTEND' ? '80' : params.CONTAINER_PORT?.trim()
299
+ def healthEndpoint = appType == 'BACKEND' ? params.HEALTH_ENDPOINT?.trim() : ''
300
+
301
+ if (!imageName) {
302
+ error 'IMAGE_NAME is required.'
303
+ }
304
+
305
+ if (!(imageName ==~ /^[a-z0-9][a-z0-9._-]*$/)) {
306
+ error 'IMAGE_NAME may contain only lowercase letters, numbers, dots, hyphens and underscores, and must start with a lowercase letter or number.'
307
+ }
308
+
309
+ def ghcrImage = params.GHCR_IMAGE?.trim()
310
+ if (!ghcrImage) {
311
+ error 'GHCR_IMAGE is required.'
312
+ }
313
+ if (!(ghcrImage ==~ /^[A-Za-z0-9.-]+(?::[0-9]+)?\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/)) {
314
+ error 'GHCR_IMAGE must be a full registry/owner/image path.'
315
+ }
316
+
317
+ def ghcrCredentialId = params.GHCR_CREDENTIAL_ID?.trim()
318
+ if (!ghcrCredentialId) {
319
+ error 'GHCR_CREDENTIAL_ID is required.'
320
+ }
321
+ if (!(ghcrCredentialId ==~ /^[A-Za-z0-9_.-]+$/)) {
322
+ error 'GHCR_CREDENTIAL_ID contains invalid characters.'
323
+ }
324
+
325
+ if (!containerName) {
326
+ error 'CONTAINER_NAME is required.'
327
+ }
328
+
329
+ if (!(containerName ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
330
+ error 'CONTAINER_NAME contains invalid characters.'
331
+ }
332
+
333
+ if (!hostPort || !(hostPort ==~ /^\d+$/)) {
334
+ error 'HOST_PORT must be a number.'
335
+ }
336
+
337
+ if (!containerPort || !(containerPort ==~ /^\d+$/)) {
338
+ error 'CONTAINER_PORT must be a number.'
339
+ }
340
+
341
+ int hostPortNumber = hostPort.toInteger()
342
+ int containerPortNumber = containerPort.toInteger()
343
+
344
+ if (hostPortNumber < 1 || hostPortNumber > 65535) {
345
+ error 'HOST_PORT must be between 1 and 65535.'
346
+ }
347
+
348
+ if (containerPortNumber < 1 || containerPortNumber > 65535) {
349
+ error 'CONTAINER_PORT must be between 1 and 65535.'
350
+ }
351
+
352
+ if (!['BACKEND', 'FRONTEND'].contains(appType)) {
353
+ error 'Select APP_TYPE as FRONTEND or BACKEND before building.'
354
+ }
355
+
356
+ if (appType == 'BACKEND') {
357
+ if (!healthEndpoint) {
358
+ error 'HEALTH_ENDPOINT is required for BACKEND.'
359
+ }
360
+
361
+ if (!healthEndpoint.startsWith('/')) {
362
+ healthEndpoint = "/${healthEndpoint}"
363
+ }
364
+
365
+ if (!(healthEndpoint ==~ '^/[A-Za-z0-9._~!$&()*+,;=:@%/?-]*$')) {
366
+ error 'HEALTH_ENDPOINT contains unsupported characters.'
367
+ }
368
+ }
369
+
370
+ def networks = appType == 'BACKEND' ? (params.DOCKER_NETWORKS
371
+ ?.split('\n')
372
+ ?.collect { it.trim() }
373
+ ?.findAll { it }
374
+ ?.unique() ?: []) : []
375
+
376
+ networks.each { network ->
377
+ if (!(network ==~ /^[A-Za-z0-9][A-Za-z0-9_.-]*$/)) {
378
+ error "Invalid Docker network name: ${network}"
379
+ }
380
+ }
381
+
382
+ def volumes = appType == 'BACKEND' ? (params.DOCKER_VOLUMES
383
+ ?.split('\n')
384
+ ?.collect { it.trim() }
385
+ ?.findAll { it } ?: []) : []
386
+
387
+ volumes.each { volume ->
388
+ if (volume.contains('\n') || volume.contains('\r')) {
389
+ error 'Docker volume entries must be one line each.'
390
+ }
391
+ if (volume.contains(';') || volume.contains('&') || volume.contains('|') || volume.contains('`') || volume.contains('$(')) {
392
+ error "Unsafe shell operator found in Docker volume entry: ${volume}"
393
+ }
394
+ }
395
+
396
+ def flags = appType == 'BACKEND' ? (params.DOCKER_FLAGS
397
+ ?.split('\n')
398
+ ?.collect { it.trim() }
399
+ ?.findAll { it } ?: []) : []
400
+
401
+ flags.each { flag ->
402
+ if (flag.contains(';') || flag.contains('&') || flag.contains('|') || flag.contains('`') || flag.contains('$(')) {
403
+ error "Unsafe shell operator found in DOCKER_FLAGS: ${flag}"
404
+ }
405
+ }
406
+
407
+ echo 'Input validation successful.'
408
+ }
409
+ }
410
+ }
411
+
412
+
413
+ stage('Check Host Port') {
414
+ steps {
415
+ script {
416
+ def hostPort = params.HOST_PORT.trim()
417
+ def containerName = params.CONTAINER_NAME.trim()
418
+
419
+ def portCheckResult = withEnv([
420
+ "CHECK_HOST_PORT=${hostPort}",
421
+ "CHECK_CONTAINER_NAME=${containerName}"
422
+ ]) {
423
+ sh(
424
+ script: '''
425
+ set -eu
426
+
427
+ PORT="$CHECK_HOST_PORT"
428
+ TARGET="$CHECK_CONTAINER_NAME"
429
+
430
+ if ! command -v docker >/dev/null 2>&1; then
431
+ echo "CHECK_ERROR|Docker CLI is not installed on this Jenkins node.|"
432
+ exit 0
433
+ fi
434
+
435
+ if ! docker ps >/dev/null 2>&1; then
436
+ echo "CHECK_ERROR|Jenkins cannot access the Docker daemon on this node.|"
437
+ exit 0
438
+ fi
439
+
440
+ if ! command -v ss >/dev/null 2>&1; then
441
+ echo "CHECK_ERROR|The ss command is required for host-port validation. Install the iproute2 package on the deployment host.|"
442
+ exit 0
443
+ fi
444
+
445
+ port_in_use() {
446
+ candidate="$1"
447
+
448
+ if docker ps --format '{{.Ports}}' 2>/dev/null | grep -Eq ":${candidate}->[0-9]+/tcp"; then
449
+ return 0
450
+ fi
451
+
452
+ if ss -H -ltn 2>/dev/null | awk -v p=":${candidate}" '$4 ~ (p "$") { found=1 } END { exit(found ? 0 : 1) }'; then
453
+ return 0
454
+ fi
455
+
456
+ return 1
457
+ }
458
+
459
+ suggest_free_ports() {
460
+ candidate=$((PORT + 1))
461
+ checked=0
462
+ found=0
463
+ result=""
464
+
465
+ while [ "$candidate" -le 65535 ] && [ "$checked" -lt 100 ] && [ "$found" -lt 5 ]; do
466
+ if ! port_in_use "$candidate"; then
467
+ if [ -n "$result" ]; then
468
+ result="$result, $candidate"
469
+ else
470
+ result="$candidate"
471
+ fi
472
+ found=$((found + 1))
473
+ fi
474
+
475
+ candidate=$((candidate + 1))
476
+ checked=$((checked + 1))
477
+ done
478
+
479
+ printf '%s' "$result"
480
+ }
481
+
482
+ docker_owner="$(
483
+ docker ps --format '{{.Names}}|{{.Ports}}' 2>/dev/null |
484
+ awk -F'|' -v p="$PORT" '$2 ~ (":" p "->[0-9]+/tcp") { print $1; exit }'
485
+ )"
486
+
487
+ if [ -n "$docker_owner" ]; then
488
+ if [ "$docker_owner" = "$TARGET" ]; then
489
+ echo "OK_TARGET|$docker_owner|"
490
+ else
491
+ suggestions="$(suggest_free_ports)"
492
+ echo "BUSY_DOCKER|$docker_owner|$suggestions"
493
+ fi
494
+ exit 0
495
+ fi
496
+
497
+ listener="$(
498
+ ss -H -ltnp 2>/dev/null |
499
+ awk -v p=":${PORT}" '$4 ~ (p "$") { print; exit }'
500
+ )"
501
+
502
+ if [ -n "$listener" ]; then
503
+ suggestions="$(suggest_free_ports)"
504
+ echo "BUSY_PROCESS|$listener|$suggestions"
505
+ exit 0
506
+ fi
507
+
508
+ echo "FREE||"
509
+ ''',
510
+ returnStdout: true
511
+ ).trim()
512
+ }
513
+
514
+ def resultParts = portCheckResult.split('\\|', -1)
515
+ def status = resultParts.length > 0 ? resultParts[0] : 'CHECK_ERROR'
516
+ def detail = resultParts.length > 1 ? resultParts[1] : ''
517
+ def suggestions = resultParts.length > 2 ? resultParts[2] : ''
518
+
519
+ if (status == 'FREE') {
520
+ echo "HOST_PORT ${hostPort} is available on the deployment host."
521
+ } else if (status == 'OK_TARGET') {
522
+ echo "HOST_PORT ${hostPort} is currently owned by the existing target container '${detail}'. This is safe for an update/redeploy."
523
+ } else if (status == 'BUSY_DOCKER') {
524
+ error "HOST_PORT ${hostPort} is already in use by Docker container '${detail}'.\n\nSuggested free ports: ${suggestions ?: 'No nearby free ports found automatically.'}\n\nChoose a different HOST_PORT and run CREATE-DEPLOYMENT again."
525
+ } else if (status == 'BUSY_PROCESS') {
526
+ error "HOST_PORT ${hostPort} is already in use by a host process.\n\nListener: ${detail}\nSuggested free ports: ${suggestions ?: 'No nearby free ports found automatically.'}\n\nChoose a different HOST_PORT and run CREATE-DEPLOYMENT again."
527
+ } else {
528
+ error "Host-port validation could not run safely: ${detail ?: portCheckResult}"
529
+ }
530
+ }
531
+ }
532
+ }
533
+
534
+ stage('Build Deployment Template') {
535
+ steps {
536
+ script {
537
+ def jobName = params.IMAGE_NAME.trim()
538
+ def imageName = params.GHCR_IMAGE?.trim()
539
+ def ghcrCredentialId = params.GHCR_CREDENTIAL_ID?.trim()
540
+ def containerName = params.CONTAINER_NAME.trim()
541
+ def hostPort = params.HOST_PORT.trim()
542
+ def appType = params.APP_TYPE.trim()
543
+ def containerPort = appType == 'FRONTEND' ? '80' : params.CONTAINER_PORT.trim()
544
+
545
+ def healthEndpoint = params.HEALTH_ENDPOINT?.trim() ?: '/health'
546
+ if (!healthEndpoint.startsWith('/')) {
547
+ healthEndpoint = "/${healthEndpoint}"
548
+ }
549
+
550
+ def networks = []
551
+ if (appType == 'BACKEND') {
552
+ networks = params.DOCKER_NETWORKS
553
+ ?.split('\n')
554
+ ?.collect { it.trim() }
555
+ ?.findAll { it && it != 'bridge' }
556
+ ?.unique() ?: []
557
+ }
558
+
559
+ def volumes = []
560
+ if (appType == 'BACKEND') {
561
+ volumes = params.DOCKER_VOLUMES
562
+ ?.split('\n')
563
+ ?.collect { it.trim() }
564
+ ?.findAll { it } ?: []
565
+ }
566
+
567
+ def flags = []
568
+ if (appType == 'BACKEND') {
569
+ flags = params.DOCKER_FLAGS
570
+ ?.split('\n')
571
+ ?.collect { it.trim() }
572
+ ?.findAll { it } ?: []
573
+ }
574
+
575
+ // Escape arbitrary command text before placing it inside a
576
+ // single-quoted Groovy string in the generated Jenkinsfile.
577
+ def groovySingleQuoteContent = { String value ->
578
+ value
579
+ .replace('\\', '\\\\')
580
+ .replace("'", "\\'")
581
+ }
582
+
583
+ def shellSingleQuote = { String value ->
584
+ return "'" + value.replace("'", "'\"'\"'") + "'"
585
+ }
586
+
587
+ def networkSetupCommand = 'true'
588
+ if (networks) {
589
+ networkSetupCommand = networks.collect { network ->
590
+ "docker network inspect ${network} >/dev/null 2>&1 || docker network create ${network}"
591
+ }.join(' ; ')
592
+ }
593
+
594
+ def additionalNetworkCommand = 'true'
595
+ if (networks) {
596
+ additionalNetworkCommand = networks.collect { network ->
597
+ "docker network connect ${network} \"\$CONTAINER_NAME\" 2>/dev/null || true"
598
+ }.join(' ; ')
599
+ }
600
+
601
+ def runParts = [
602
+ 'docker run -d',
603
+ '--name "$CONTAINER_NAME"',
604
+ '--restart unless-stopped',
605
+ '-p "$HOST_PORT:$CONTAINER_PORT"',
606
+ '--network bridge'
607
+ ]
608
+
609
+ volumes.each { volume ->
610
+ runParts << "-v ${shellSingleQuote(volume)}"
611
+ }
612
+
613
+ flags.each { flag ->
614
+ runParts << flag
615
+ }
616
+
617
+ if (appType == 'BACKEND') {
618
+ runParts << "--health-cmd=\"curl -fsS http://localhost:\$CONTAINER_PORT${healthEndpoint} || exit 1\""
619
+ runParts << '--health-interval=30s'
620
+ runParts << '--health-timeout=5s'
621
+ runParts << '--health-retries=3'
622
+ runParts << '--health-start-period=20s'
623
+ }
624
+
625
+ runParts << '"$DEPLOY_IMAGE_REF"'
626
+ def dockerRunCommand = runParts.join(' ')
627
+
628
+ def loginCommand = 'REGISTRY="${IMAGE_NAME%%/*}"; echo "$GH_TOKEN" | docker login "$REGISTRY" -u "$GH_USER" --password-stdin'
629
+ def pullCommand = 'docker pull "$DEPLOY_IMAGE_REF"'
630
+ def stopCommand = 'docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true; docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true'
631
+ def portPrecheckCommand = 'set -eu; if ! command -v docker >/dev/null 2>&1; then echo \"ERROR: Docker CLI is missing on this Jenkins node.\"; exit 1; fi; if ! docker ps >/dev/null 2>&1; then echo \"ERROR: Jenkins cannot access the Docker daemon on this node.\"; exit 1; fi; if ! command -v ss >/dev/null 2>&1; then echo \"ERROR: ss is required for host-port validation (install iproute2).\"; exit 1; fi; owner=\"$(docker ps --format \'{{.Names}}|{{.Ports}}\' 2>/dev/null | awk -F\'|\' -v p=\"$HOST_PORT\" \'$2 ~ (\":\" p \"->[0-9]+/tcp\") { print $1; exit }\')\"; if [ -n \"$owner\" ] && [ \"$owner\" != \"$CONTAINER_NAME\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is already used by Docker container $owner.\"; exit 1; fi; if [ -z \"$owner\" ]; then listener=\"$(ss -H -ltnp 2>/dev/null | awk -v p=\":$HOST_PORT\" \'$4 ~ (p \"$\") { print; exit }\')\"; if [ -n \"$listener\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is already used by a host process: $listener\"; exit 1; fi; fi; if [ \"$owner\" = \"$CONTAINER_NAME\" ]; then echo \"HOST_PORT $HOST_PORT is currently owned by the target container $CONTAINER_NAME; redeploy is allowed.\"; else echo \"HOST_PORT $HOST_PORT is available.\"; fi'
632
+ def portReleasedCommand = 'set -eu; owner=\"$(docker ps --format \'{{.Names}}|{{.Ports}}\' 2>/dev/null | awk -F\'|\' -v p=\"$HOST_PORT\" \'$2 ~ (\":\" p \"->[0-9]+/tcp\") { print $1; exit }\')\"; if [ -n \"$owner\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT became occupied by Docker container $owner before docker run.\"; exit 1; fi; listener=\"$(ss -H -ltnp 2>/dev/null | awk -v p=\":$HOST_PORT\" \'$4 ~ (p \"$\") { print; exit }\')\"; if [ -n \"$listener\" ]; then echo \"ERROR: HOST_PORT $HOST_PORT is still occupied after stopping the old container: $listener\"; exit 1; fi; echo \"HOST_PORT $HOST_PORT is free immediately before docker run.\"'
633
+ def frontendCheckCommand = 'docker inspect "$CONTAINER_NAME" >/dev/null && docker ps --filter "name=^${CONTAINER_NAME}$" --filter "status=running" --format "{{.Names}}" | grep -Fx "$CONTAINER_NAME"'
634
+ def cleanupContainersCommand = 'docker container prune -f'
635
+ def cleanupImagesCommand = 'docker image prune -af --filter "until=168h"'
636
+ def cleanupBuilderCommand = 'docker builder prune -af --filter "until=168h"'
637
+
638
+ def deploymentPipeline = '''
639
+ pipeline {
640
+ agent any
641
+
642
+ options {
643
+ quietPeriod(10)
644
+ disableConcurrentBuilds()
645
+ }
646
+
647
+ parameters {
648
+ choice(
649
+ name: 'ACTION',
650
+ choices: ['DEPLOY', 'ROLLBACK'],
651
+ description: 'Deploy a new image or rollback to another image tag.'
652
+ )
653
+ string(
654
+ name: 'IMAGE_TAG',
655
+ defaultValue: '',
656
+ description: 'Docker image tag for manual deploy/rollback.',
657
+ trim: true
658
+ )
659
+ string(
660
+ name: 'IMAGE_DIGEST',
661
+ defaultValue: '',
662
+ description: 'Optional sha256 image digest. If supplied, it is used instead of the mutable tag.',
663
+ trim: true
664
+ )
665
+ string(
666
+ name: 'DEPLOYMENT_SOURCE',
667
+ defaultValue: 'MANUAL',
668
+ description: 'Deployment source.',
669
+ trim: true
670
+ )
671
+ }
672
+
673
+ triggers {
674
+ GenericTrigger(
675
+ genericVariables: [
676
+ [key: 'PACKAGE_NAME', value: '$.package.name'],
677
+ [key: 'PACKAGE_TAG', value: '$.package.package_version.container_metadata.tag.name'],
678
+ [key: 'PACKAGE_DIGEST', value: '$.package.package_version.container_metadata.tag.digest'],
679
+ [key: 'EVENT_ACTION', value: '$.action']
680
+ ],
681
+ causeString: 'GHCR: $PACKAGE_NAME:$PACKAGE_TAG',
682
+ token: 'wholesum-order-webhook',
683
+ printContributedVariables: true,
684
+ printPostContent: false,
685
+ silentResponse: false,
686
+ allowSeveralTriggersPerBuild: false,
687
+ regexpFilterText: '$EVENT_ACTION:$PACKAGE_NAME:$PACKAGE_TAG',
688
+ regexpFilterExpression: '^published:__PACKAGE_NAME_REGEX__:.+$'
689
+ )
690
+ }
691
+
692
+ environment {
693
+ IMAGE_NAME = '__IMAGE_NAME__'
694
+ CONTAINER_NAME = '__CONTAINER_NAME__'
695
+ HOST_PORT = '__HOST_PORT__'
696
+ CONTAINER_PORT = '__CONTAINER_PORT__'
697
+ }
698
+
699
+ stages {
700
+ stage('Resolve Deployment') {
701
+ steps {
702
+ script {
703
+ if (env.PACKAGE_TAG?.trim()) {
704
+ env.DEPLOY_TAG = env.PACKAGE_TAG.trim()
705
+ env.DEPLOY_SOURCE = 'GITHUB_WEBHOOK'
706
+ env.DEPLOY_ACTION = 'DEPLOY'
707
+ echo 'GHCR webhook deployment detected.'
708
+ } else {
709
+ if (!params.IMAGE_TAG?.trim()) {
710
+ error 'IMAGE_TAG is required for manual deployment.'
711
+ }
712
+
713
+ env.DEPLOY_TAG = params.IMAGE_TAG.trim()
714
+ env.DEPLOY_SOURCE = params.DEPLOYMENT_SOURCE?.trim() ?: 'MANUAL'
715
+ env.DEPLOY_ACTION = params.ACTION ?: 'DEPLOY'
716
+ echo 'Manual deployment detected.'
717
+ }
718
+
719
+ if (!(env.DEPLOY_TAG ==~ /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/)) {
720
+ error "Invalid Docker image tag: ${env.DEPLOY_TAG}"
721
+ }
722
+
723
+ def requestedDigest = env.PACKAGE_DIGEST?.trim()
724
+ if (!requestedDigest) {
725
+ requestedDigest = params.IMAGE_DIGEST?.trim()
726
+ }
727
+
728
+ if (requestedDigest) {
729
+ if (!(requestedDigest ==~ /^sha256:[a-fA-F0-9]{64}$/)) {
730
+ error "Invalid image digest: ${requestedDigest}"
731
+ }
732
+ env.DEPLOY_DIGEST = requestedDigest
733
+ env.DEPLOY_IMAGE_REF = "${env.IMAGE_NAME}@${requestedDigest}"
734
+ } else {
735
+ env.DEPLOY_DIGEST = ''
736
+ env.DEPLOY_IMAGE_REF = "${env.IMAGE_NAME}:${env.DEPLOY_TAG}"
737
+ }
738
+
739
+ echo ''
740
+ echo '=============================================='
741
+ echo ' DEPLOYMENT START'
742
+ echo '=============================================='
743
+ echo ''
744
+ echo 'Service : __JOB_NAME__'
745
+ echo "Image : ${env.IMAGE_NAME}"
746
+ echo "Tag : ${env.DEPLOY_TAG}"
747
+ if (env.DEPLOY_DIGEST) {
748
+ echo "Digest : ${env.DEPLOY_DIGEST}"
749
+ }
750
+ echo "Container : ${env.CONTAINER_NAME}"
751
+ echo "Action : ${env.DEPLOY_ACTION}"
752
+ echo "Source : ${env.DEPLOY_SOURCE}"
753
+ echo ''
754
+ }
755
+ }
756
+ }
757
+
758
+
759
+ stage('Validate Host Port') {
760
+ steps {
761
+ sh '__PORT_PRECHECK_COMMAND__'
762
+ }
763
+ }
764
+
765
+ stage('Login to GHCR') {
766
+ steps {
767
+ withCredentials([
768
+ usernamePassword(
769
+ credentialsId: '__GHCR_CREDENTIAL_ID__',
770
+ usernameVariable: 'GH_USER',
771
+ passwordVariable: 'GH_TOKEN'
772
+ )
773
+ ]) {
774
+ sh '__LOGIN_COMMAND__'
775
+ }
776
+ }
777
+ }
778
+
779
+ stage('Pull Image') {
780
+ steps {
781
+ sh '__PULL_COMMAND__'
782
+ }
783
+ }
784
+
785
+ stage('Prepare Docker Networks') {
786
+ when {
787
+ expression {
788
+ return '__APP_TYPE__' == 'BACKEND'
789
+ }
790
+ }
791
+ steps {
792
+ sh '__NETWORK_SETUP_COMMAND__'
793
+ }
794
+ }
795
+
796
+ stage('Stop Existing Container') {
797
+ steps {
798
+ sh '__STOP_COMMAND__'
799
+ }
800
+ }
801
+
802
+
803
+ stage('Confirm Host Port Released') {
804
+ steps {
805
+ sh '__PORT_RELEASED_COMMAND__'
806
+ }
807
+ }
808
+
809
+ stage('Run New Container') {
810
+ steps {
811
+ sh '__RUN_COMMAND__'
812
+ }
813
+ }
814
+
815
+ stage('Connect Additional Networks') {
816
+ when {
817
+ expression {
818
+ return '__APP_TYPE__' == 'BACKEND'
819
+ }
820
+ }
821
+ steps {
822
+ sh '__CONNECT_NETWORK_COMMAND__'
823
+ }
824
+ }
825
+
826
+ stage('Health Check') {
827
+ when {
828
+ expression {
829
+ return '__APP_TYPE__' == 'BACKEND'
830
+ }
831
+ }
832
+ steps {
833
+ script {
834
+ echo ''
835
+ echo 'Waiting for Docker health check...'
836
+ echo ''
837
+
838
+ timeout(time: 2, unit: 'MINUTES') {
839
+ waitUntil {
840
+ def status = sh(
841
+ script: 'docker inspect --format="{{.State.Health.Status}}" "$CONTAINER_NAME"',
842
+ returnStdout: true
843
+ ).trim()
844
+
845
+ echo "Container health: ${status}"
846
+
847
+ if (status == 'healthy') {
848
+ echo 'Health check PASSED.'
849
+ return true
850
+ }
851
+
852
+ if (status == 'unhealthy') {
853
+ sh 'docker logs --tail 200 "$CONTAINER_NAME" || true'
854
+ error 'Container health check FAILED.'
855
+ }
856
+
857
+ sleep 5
858
+ return false
859
+ }
860
+ }
861
+ }
862
+ }
863
+ }
864
+
865
+ stage('Frontend Running Check') {
866
+ when {
867
+ expression {
868
+ return '__APP_TYPE__' == 'FRONTEND'
869
+ }
870
+ }
871
+ steps {
872
+ sh '__FRONTEND_CHECK_COMMAND__'
873
+ }
874
+ }
875
+
876
+ stage('Docker Cleanup') {
877
+ steps {
878
+ script {
879
+ echo ''
880
+ echo '=============================================='
881
+ echo ' DOCKER CLEANUP'
882
+ echo '=============================================='
883
+ echo ''
884
+
885
+ echo 'Removing stopped containers...'
886
+ sh '__CLEANUP_CONTAINERS_COMMAND__'
887
+
888
+ echo 'Removing unused images older than 7 days...'
889
+ sh '__CLEANUP_IMAGES_COMMAND__'
890
+
891
+ echo 'Removing unused build cache older than 7 days...'
892
+ sh '__CLEANUP_BUILDER_COMMAND__'
893
+
894
+ echo ''
895
+ echo 'Docker cleanup completed. Volumes were not pruned.'
896
+ echo ''
897
+ }
898
+ }
899
+ }
900
+ }
901
+
902
+ post {
903
+ success {
904
+ slackSend(
905
+ channel: '#jenkins-deployment',
906
+ color: 'good',
907
+ message: """
908
+ 🟢 *DEPLOYMENT SUCCESSFUL*
909
+
910
+ *Service:* __JOB_NAME__
911
+ *Image:* ${env.IMAGE_NAME}
912
+ *Tag:* ${env.DEPLOY_TAG}
913
+ *Container:* ${env.CONTAINER_NAME}
914
+ *Action:* ${env.DEPLOY_ACTION}
915
+ *Source:* ${env.DEPLOY_SOURCE}
916
+ *Build:* #${env.BUILD_NUMBER}
917
+
918
+ Deployment completed successfully.
919
+ <${env.BUILD_URL}|View Jenkins Build>
920
+ """.stripIndent()
921
+ )
922
+ }
923
+
924
+ failure {
925
+ script {
926
+ sh 'docker logs --tail 200 "$CONTAINER_NAME" || true'
927
+ }
928
+ slackSend(
929
+ channel: '#jenkins-deployment',
930
+ color: 'danger',
931
+ message: """
932
+ 🔴 *DEPLOYMENT FAILED*
933
+
934
+ *Service:* __JOB_NAME__
935
+ *Image:* ${env.IMAGE_NAME}
936
+ *Tag:* ${env.DEPLOY_TAG ?: 'UNKNOWN'}
937
+ *Container:* ${env.CONTAINER_NAME}
938
+ *Action:* ${env.DEPLOY_ACTION ?: 'UNKNOWN'}
939
+ *Source:* ${env.DEPLOY_SOURCE ?: 'UNKNOWN'}
940
+ *Build:* #${env.BUILD_NUMBER}
941
+
942
+ Deployment or health check failed.
943
+ <${env.BUILD_URL}|View Jenkins Build>
944
+ """.stripIndent()
945
+ )
946
+ }
947
+ }
948
+ }
949
+ '''
950
+
951
+ deploymentPipeline = deploymentPipeline
952
+ .replace('__JOB_NAME__', jobName)
953
+ .replace('__IMAGE_NAME__', imageName)
954
+ .replace('__CONTAINER_NAME__', containerName)
955
+ .replace('__HOST_PORT__', hostPort)
956
+ .replace('__CONTAINER_PORT__', containerPort)
957
+ .replace('__APP_TYPE__', appType)
958
+ .replace('__GHCR_CREDENTIAL_ID__', ghcrCredentialId)
959
+ .replace('__PACKAGE_NAME_REGEX__', jobName)
960
+ .replace('__LOGIN_COMMAND__', groovySingleQuoteContent(loginCommand))
961
+ .replace('__PULL_COMMAND__', groovySingleQuoteContent(pullCommand))
962
+ .replace('__NETWORK_SETUP_COMMAND__', groovySingleQuoteContent(networkSetupCommand))
963
+ .replace('__STOP_COMMAND__', groovySingleQuoteContent(stopCommand))
964
+ .replace('__PORT_PRECHECK_COMMAND__', groovySingleQuoteContent(portPrecheckCommand))
965
+ .replace('__PORT_RELEASED_COMMAND__', groovySingleQuoteContent(portReleasedCommand))
966
+ .replace('__RUN_COMMAND__', groovySingleQuoteContent(dockerRunCommand))
967
+ .replace('__CONNECT_NETWORK_COMMAND__', groovySingleQuoteContent(additionalNetworkCommand))
968
+ .replace('__FRONTEND_CHECK_COMMAND__', groovySingleQuoteContent(frontendCheckCommand))
969
+ .replace('__CLEANUP_CONTAINERS_COMMAND__', groovySingleQuoteContent(cleanupContainersCommand))
970
+ .replace('__CLEANUP_IMAGES_COMMAND__', groovySingleQuoteContent(cleanupImagesCommand))
971
+ .replace('__CLEANUP_BUILDER_COMMAND__', groovySingleQuoteContent(cleanupBuilderCommand))
972
+
973
+ // The generated child Jenkinsfile deliberately contains no
974
+ // triple-single-quoted strings. That makes it safe to embed
975
+ // directly in Job DSL without prematurely closing this literal.
976
+ if (deploymentPipeline.contains("'''")) {
977
+ error 'Internal generator error: generated pipeline contains a forbidden triple-single-quote sequence.'
978
+ }
979
+
980
+ // Keep this Job DSL script CONSTANT. Jenkins Script Security
981
+ // approves unsandboxed scripts by their exact content/hash.
982
+ // Dynamic service values are supplied separately through
983
+ // additionalParameters, so creating another deployment does not
984
+ // produce a brand-new Job DSL script requiring another approval.
985
+ def dslScript = '''
986
+ pipelineJob(JOB_NAME) {
987
+ description("""
988
+ Automatically generated Docker deployment pipeline.
989
+
990
+ Service: ${JOB_NAME}
991
+ GHCR Image: ${GHCR_IMAGE}
992
+ GHCR Credential: ${GHCR_CREDENTIAL_ID}
993
+ Container: ${DOCKER_CONTAINER}
994
+ Host Port: ${DOCKER_HOST_PORT}
995
+ Container Port: ${DOCKER_CONTAINER_PORT}
996
+ Application Type: ${APPLICATION_TYPE}
997
+ Generated by: CREATE-DEPLOYMENT
998
+ """.stripIndent())
999
+
1000
+ logRotator {
1001
+ numToKeep(50)
1002
+ }
1003
+
1004
+ definition {
1005
+ cps {
1006
+ script(PIPELINE_SCRIPT)
1007
+ sandbox()
1008
+ }
1009
+ }
1010
+ }
1011
+ '''
1012
+
1013
+ // The seed Job DSL is unsandboxed because this Jenkins instance
1014
+ // currently executes CREATE-DEPLOYMENT as SYSTEM. Unlike v2,
1015
+ // scriptText above never changes. Approve this constant DSL once;
1016
+ // future service-specific values are only bound parameters.
1017
+ // The generated child Pipeline itself remains sandboxed.
1018
+ jobDsl(
1019
+ scriptText: dslScript,
1020
+ additionalParameters: [
1021
+ JOB_NAME: jobName,
1022
+ GHCR_IMAGE: imageName,
1023
+ GHCR_CREDENTIAL_ID: ghcrCredentialId,
1024
+ DOCKER_CONTAINER: containerName,
1025
+ DOCKER_HOST_PORT: hostPort,
1026
+ DOCKER_CONTAINER_PORT: containerPort,
1027
+ APPLICATION_TYPE: appType,
1028
+ PIPELINE_SCRIPT: deploymentPipeline
1029
+ ],
1030
+ sandbox: false,
1031
+ removedJobAction: 'IGNORE',
1032
+ removedViewAction: 'IGNORE',
1033
+ lookupStrategy: 'JENKINS_ROOT'
1034
+ )
1035
+
1036
+ // Give the IndraQ user that requested this deployment access
1037
+ // to the generated job without granting visibility/configure
1038
+ // rights to unrelated Jenkins jobs. This is best-effort so a
1039
+ // deployment is not destroyed merely because Role Strategy is
1040
+ // not installed/configured yet; unified user preflight normally
1041
+ // guarantees that it is available.
1042
+ def indraqUsername = params.INDRAQ_USERNAME?.trim()
1043
+ if (indraqUsername) {
1044
+ try {
1045
+ def strategy = jenkins.model.Jenkins.get().authorizationStrategy
1046
+ if (strategy instanceof com.michelin.cio.hudson.plugins.rolestrategy.RoleBasedAuthorizationStrategy) {
1047
+ def roleMap = strategy.getRoleMap(com.synopsys.arc.jenkins.plugins.rolestrategy.RoleType.Project)
1048
+ def roleName = ('indraq-owner-' + indraqUsername + '-' + jobName).replaceAll('[^A-Za-z0-9_.-]', '-').take(120)
1049
+ def oldRole = roleMap.getRole(roleName)
1050
+ if (oldRole != null) roleMap.removeRole(oldRole)
1051
+ Set<hudson.security.Permission> permissions = new HashSet<hudson.security.Permission>()
1052
+ permissions.add(hudson.model.Item.DISCOVER)
1053
+ permissions.add(hudson.model.Item.READ)
1054
+ permissions.add(hudson.model.Item.BUILD)
1055
+ permissions.add(hudson.model.Item.CANCEL)
1056
+ permissions.add(hudson.model.Item.CONFIGURE)
1057
+ permissions.add(hudson.model.Item.WORKSPACE)
1058
+ def role = new com.michelin.cio.hudson.plugins.rolestrategy.Role(roleName, '^' + java.util.regex.Pattern.quote(jobName) + '$', permissions)
1059
+ roleMap.addRole(role)
1060
+ roleMap.assignRole(role, new com.michelin.cio.hudson.plugins.rolestrategy.PermissionEntry(com.michelin.cio.hudson.plugins.rolestrategy.AuthorizationType.USER, indraqUsername))
1061
+ jenkins.model.Jenkins.get().save()
1062
+ echo "Granted Jenkins item access to ${indraqUsername} for ${jobName}."
1063
+ } else {
1064
+ echo 'WARNING: Role Strategy is not active; generated job access was not assigned automatically.'
1065
+ }
1066
+ } catch (Throwable accessError) {
1067
+ echo "WARNING: Generated job was created, but item access could not be assigned automatically: ${accessError.message}"
1068
+ }
1069
+ }
1070
+ echo ''
1071
+ echo '=============================================='
1072
+ echo ' DEPLOYMENT JOB CREATED / UPDATED'
1073
+ echo '=============================================='
1074
+ echo ''
1075
+ echo "Jenkins Job: ${jobName}"
1076
+ echo "GHCR Image: ${imageName}"
1077
+ echo "Container: ${containerName}"
1078
+ echo "Application: ${appType}"
1079
+ if (appType == 'FRONTEND') {
1080
+ echo 'Frontend container port: 80 (FIXED)'
1081
+ echo 'Frontend backend-only Docker settings: DISABLED'
1082
+ }
1083
+ echo "Host Port: ${hostPort}"
1084
+ echo 'Host-port availability checks: ENABLED (creation + deployment)'
1085
+ echo "Container Port: ${containerPort}"
1086
+ echo 'Default Docker Network: bridge'
1087
+
1088
+ if (appType == 'BACKEND') {
1089
+ echo "Additional Networks: ${networks ? networks.join(', ') : 'None'}"
1090
+ echo "Health Endpoint: ${healthEndpoint}"
1091
+ }
1092
+
1093
+ echo 'Automatic GHCR deployment: ENABLED'
1094
+ echo 'Manual deployment: ENABLED'
1095
+ echo 'Manual rollback: ENABLED'
1096
+ echo 'Slack notifications: ENABLED'
1097
+ echo 'Docker cleanup: ENABLED'
1098
+ echo 'Image retention: 7 days'
1099
+ echo 'Volume pruning: DISABLED'
1100
+ echo ''
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ }