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.
- package/CHANGELOG_v2.1.0.md +27 -27
- package/CHANGELOG_v2.2.0.md +52 -0
- package/LICENSE +21 -21
- package/README.md +27 -14
- package/RELEASE_CHECKS_v2.2.0.md +27 -0
- package/UPGRADE_v2.0_to_v2.1.md +68 -68
- package/UPGRADE_v2.1_to_v2.2.md +62 -0
- package/cloud-api/.dockerignore +18 -18
- package/cloud-api/.env.example +14 -14
- package/cloud-api/Dockerfile +19 -19
- package/cloud-api/README.md +93 -93
- package/cloud-api/dist/index.js +265 -27
- package/cloud-api/node_modules/.package-lock.json +1 -1
- package/cloud-api/package-lock.json +2 -2
- package/cloud-api/package.json +1 -1
- package/cloud-api/sql/001_init.sql +8 -8
- package/cloud-api/sql/002_user_providers_and_project_resources.sql +40 -40
- package/cloud-api/sql/003_environment_infrastructure.sql +19 -19
- package/cloud-api/sql/004_managed_aws_credentials.sql +20 -20
- package/cloud-api/sql/005_project_members_and_resource_grants.sql +22 -22
- package/cloud-api/sql/006_catalog_identity_password_security.sql +96 -96
- package/cloud-api/sql/007_aws_access_key_rotation.sql +16 -16
- package/cloud-api/sql/008_provider_scopes.sql +91 -91
- package/cloud-api/sql/009_scope_user_secrets_and_project_lifecycle.sql +13 -0
- package/cloud-api/src/index.ts +164 -27
- package/cloud-api/tsconfig.json +18 -18
- package/dist/cli/help-content.d.ts.map +1 -1
- package/dist/cli/help-content.js +11 -5
- package/dist/cli/help-content.js.map +1 -1
- package/dist/modules/access/commands/access.command.js +30 -30
- package/dist/modules/aws/services/route53-lookup.service.js.map +1 -1
- package/dist/modules/cloud/commands/cloud.command.d.ts +4 -0
- package/dist/modules/cloud/commands/cloud.command.d.ts.map +1 -1
- package/dist/modules/cloud/commands/cloud.command.js +52 -1
- package/dist/modules/cloud/commands/cloud.command.js.map +1 -1
- package/dist/modules/cloud/index.d.ts.map +1 -1
- package/dist/modules/cloud/index.js +4 -1
- package/dist/modules/cloud/index.js.map +1 -1
- package/dist/modules/cloud/services/cloud-jenkins.service.js +71 -71
- package/dist/modules/cloud/services/user-provider.service.d.ts +2 -8
- package/dist/modules/cloud/services/user-provider.service.d.ts.map +1 -1
- package/dist/modules/cloud/services/user-provider.service.js +7 -35
- package/dist/modules/cloud/services/user-provider.service.js.map +1 -1
- package/dist/modules/deploy/commands/build.command.d.ts +1 -0
- package/dist/modules/deploy/commands/build.command.d.ts.map +1 -1
- package/dist/modules/deploy/commands/build.command.js +12 -5
- package/dist/modules/deploy/commands/build.command.js.map +1 -1
- package/dist/modules/deploy/index.d.ts.map +1 -1
- package/dist/modules/deploy/index.js +11 -4
- package/dist/modules/deploy/index.js.map +1 -1
- package/dist/modules/prebuild/commands/prebuild.command.d.ts +0 -4
- package/dist/modules/prebuild/commands/prebuild.command.d.ts.map +1 -1
- package/dist/modules/prebuild/commands/prebuild.command.js +34 -38
- package/dist/modules/prebuild/commands/prebuild.command.js.map +1 -1
- package/dist/modules/prebuild/index.d.ts.map +1 -1
- package/dist/modules/prebuild/index.js +4 -8
- package/dist/modules/prebuild/index.js.map +1 -1
- package/dist/modules/scaffold/services/docker-template.service.js +95 -95
- package/dist/modules/scopes/commands/scope.command.d.ts.map +1 -1
- package/dist/modules/scopes/commands/scope.command.js +3 -47
- package/dist/modules/scopes/commands/scope.command.js.map +1 -1
- package/dist/modules/scopes/services/scope-native.service.js +2 -2
- package/dist/modules/scopes/services/scope-native.service.js.map +1 -1
- package/dist/modules/users/commands/user.command.d.ts +1 -0
- package/dist/modules/users/commands/user.command.d.ts.map +1 -1
- package/dist/modules/users/commands/user.command.js +15 -1
- package/dist/modules/users/commands/user.command.js.map +1 -1
- package/dist/modules/users/index.d.ts.map +1 -1
- package/dist/modules/users/index.js +1 -0
- package/dist/modules/users/index.js.map +1 -1
- package/dist/modules/users/providers/jenkins-user.provider.js +95 -95
- package/dist/shared/config/config-files.js +7 -7
- package/dist/shared/jenkins/jenkins.service.d.ts.map +1 -1
- package/dist/shared/jenkins/jenkins.service.js +13 -8
- package/dist/shared/jenkins/jenkins.service.js.map +1 -1
- package/dist/shared/jenkins/role-strategy-groovy.js +84 -84
- package/docs/ARCHITECTURE.md +29 -29
- package/docs/CREATE-DEPLOYMENT-reference.groovy +1105 -1105
- package/package.json +9 -5
- package/templates/jenkins/CREATE-DEPLOYMENT.groovy +1105 -1105
- package/templates/jenkins/Jenkinsfile-Mobile-App +759 -759
- 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('&', '&')
|
|
78
|
-
.replace('<', '<')
|
|
79
|
-
.replace('>', '>')
|
|
80
|
-
.replace('"', '"')
|
|
81
|
-
.replace("'", ''')
|
|
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->5000/tcp or [::]:6017->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('&', '&')
|
|
78
|
+
.replace('<', '<')
|
|
79
|
+
.replace('>', '>')
|
|
80
|
+
.replace('"', '"')
|
|
81
|
+
.replace("'", ''')
|
|
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->5000/tcp or [::]:6017->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
|
+
}
|