@ibm-cloud/cd-tools 1.0.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.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Licensed Materials - Property of IBM
3
+ * (c) Copyright IBM Corporation 2025. All Rights Reserved.
4
+ *
5
+ * Note to U.S. Government Users Restricted Rights:
6
+ * Use, duplication or disclosure restricted by GSA ADP Schedule
7
+ * Contract with IBM Corp.
8
+ */
9
+
10
+ import { execSync } from 'child_process';
11
+ import { logger, LOG_STAGES } from './logger.js'
12
+ import { RESERVED_GRIT_PROJECT_NAMES, RESERVED_GRIT_GROUP_NAMES, RESERVED_GRIT_SUBGROUP_NAME, TERRAFORM_REQUIRED_VERSION, TERRAFORMER_REQUIRED_VERSION } from '../../config.js';
13
+ import { getToolchainsByName, getToolchainTools, getPipelineData, getAppConfigHealthcheck, getSecretsHealthcheck, getGitOAuth, getGritUserProject, getGritGroup, getGritGroupProject } from './requests.js';
14
+ import { promptUserConfirmation, promptUserInput } from './utils.js';
15
+
16
+
17
+ const SECRETS_MAP = {
18
+ 'artifactory': ['token'],
19
+ 'hashicorpvault': ['token', 'role_id', 'secret_id', 'password'],
20
+ 'jenkins': ['webhook_url', 'api_token'],
21
+ 'jira': ['api_token'],
22
+ 'nexus': ['token'],
23
+ 'pagerduty': ['service_key'],
24
+ 'privateworker': ['worker_queue_credentials'],
25
+ 'saucelabs': ['access_key'],
26
+ 'securitycompliance': ['scc_api_key'],
27
+ 'slack': ['webhook'],
28
+ 'sonarqube': ['user_password'],
29
+ 'gitlab': ['api_token'],
30
+ 'githubconsolidated': ['api_token'],
31
+ 'github_integrated': ['api_token']
32
+ };
33
+
34
+
35
+ function validatePrereqsVersions() {
36
+ const compareVersions = (verInstalled, verRequired) => {
37
+ const installedSplit = verInstalled.split('.').map(Number);
38
+ const requiredSplit = verRequired.split('.').map(Number);
39
+ for (let j = 0; j < Math.max(installedSplit.length, requiredSplit.length); j++) {
40
+ const i = installedSplit[j] || 0;
41
+ const r = requiredSplit[j] || 0;
42
+ if (i > r) return true;
43
+ if (i < r) return false;
44
+ }
45
+ return true;
46
+ };
47
+
48
+ let stdout;
49
+ let version;
50
+
51
+ try {
52
+ stdout = execSync('terraform version').toString();
53
+ } catch {
54
+ throw Error('Terraform is not installed or not in PATH');
55
+ }
56
+ version = stdout.match(/\d+(\.\d+)+/)[0];
57
+ if (!compareVersions(version, TERRAFORM_REQUIRED_VERSION)) {
58
+ throw Error(`Terraform does not meet minimum version requirement: ${TERRAFORM_REQUIRED_VERSION}`);
59
+ }
60
+ logger.info(`\x1b[32m✔\x1b[0m Terraform Version: ${version}`, LOG_STAGES.setup);
61
+
62
+ try {
63
+ stdout = execSync('terraformer version').toString();
64
+ } catch {
65
+ throw Error('Terraformer is not installed or not in PATH');
66
+ }
67
+ version = stdout.match(/\d+(\.\d+)+/)[0];
68
+ if (!compareVersions(version, TERRAFORMER_REQUIRED_VERSION)) {
69
+ throw Error(`Terraformer does not meet minimum version requirement: ${TERRAFORMER_REQUIRED_VERSION}`);
70
+ }
71
+ logger.info(`\x1b[32m✔\x1b[0m Terraformer Version: ${version}`, LOG_STAGES.setup);
72
+ }
73
+
74
+ function validateToolchainId(tcId) {
75
+ if (typeof tcId != 'string') throw Error('Provided toolchain ID is not a string');
76
+ const trimmed = tcId.trim();
77
+
78
+ // pattern from api docs
79
+ const pattern = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/;
80
+ if (pattern.test(trimmed)) {
81
+ return trimmed;
82
+ }
83
+ throw Error('Provided toolchain ID is invalid');
84
+ }
85
+
86
+ function validateToolchainName(tcName) {
87
+ if (typeof tcName != 'string') throw Error('Provided toolchain name is not a string');
88
+ const trimmed = tcName.trim();
89
+
90
+ // pattern from api docs
91
+ const pattern = /^([^\x00-\x7F]|[a-zA-Z0-9-._ ])+$/;
92
+ if (trimmed.length <= 128 && pattern.test(trimmed.trim())) {
93
+ return trimmed;
94
+ }
95
+ throw Error('Provided toolchain name is invalid');
96
+ }
97
+
98
+ function validateResourceGroupId(rgId) {
99
+ if (typeof rgId != 'string') throw Error('Provided resource group ID is not a string');
100
+ const trimmed = rgId.trim();
101
+
102
+ // pattern from api docs
103
+ const pattern = /^[0-9a-f]{32}$/;
104
+ if (pattern.test(trimmed)) {
105
+ return trimmed;
106
+ }
107
+ throw Error('Provided resource group ID is invalid');
108
+ }
109
+
110
+ function validateTag(tag) {
111
+ if (typeof tag != 'string') throw Error('Provided resource group ID is not a string');
112
+ const trimmed = tag.trim();
113
+
114
+ // only contains alphanumeric characters, spaces, underscores, dashes, periods and colons not at start or end
115
+ const pattern = /^[a-zA-Z0-9-._ ]{1,126}[a-zA-Z0-9-._ :]{1,126}[a-zA-Z0-9-._ ]{1,126}$|^([a-zA-Z0-9-._ ]{1,128})$/;
116
+ if (trimmed.length <= 128 && pattern.test(trimmed.trim())) {
117
+ return trimmed;
118
+ }
119
+ throw Error('Provided tag is invalid');
120
+ }
121
+
122
+
123
+ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegion, targetResourceGroupId, targetTag, skipPrompt) {
124
+ const toolchains = await getToolchainsByName(token, accountId, tcName);
125
+
126
+ let hasSameRegion = false;
127
+ let hasSameResourceGroup = false;
128
+ let hasBoth = false;
129
+
130
+ if (toolchains.length > 0) {
131
+ let newTcName = tcName;
132
+ let newTag = targetTag;
133
+
134
+ toolchains.forEach((tc) => {
135
+ if (tc.region_id === targetRegion) {
136
+ if (tc.resource_group_id === targetResourceGroupId) {
137
+ hasBoth = true;
138
+ } else {
139
+ hasSameRegion = true;
140
+ }
141
+ } else if (tc.resource_group_id === targetResourceGroupId) {
142
+ hasSameResourceGroup = true;
143
+ }
144
+ });
145
+
146
+ if (hasBoth) {
147
+ // warning! prompt user to cancel, rename (e.g. add a suffix) or continue
148
+ logger.warn('Warning! You have a toolchain with the same name within the target region and resource group! \n', LOG_STAGES.setup, true);
149
+
150
+ if (!skipPrompt) {
151
+ newTcName = await promptUserInput('(Recommended) Change the cloned toolchain\'s name:\n', tcName, validateToolchainName);
152
+ }
153
+ } else {
154
+ if (hasSameRegion) {
155
+ // soft warning of confusion
156
+ logger.warn('Warning! You have a toolchain with the same name within the target region!\n', LOG_STAGES.setup, true);
157
+ }
158
+ if (hasSameResourceGroup) {
159
+ // soft warning of confusion
160
+ logger.warn('Warning! You have a toolchain with the same name within the target resource group!\n', LOG_STAGES.setup, true);
161
+ }
162
+ }
163
+
164
+ if (hasBoth || hasSameRegion || hasSameResourceGroup) {
165
+ // suggest a tag, if one not provided
166
+ if (!targetTag) {
167
+ if (!skipPrompt) {
168
+ const validateTagOrEmpty = (str) => {
169
+ if (str.trim() === '') return null;
170
+ return validateTag(str);
171
+ }
172
+ newTag = await promptUserInput('(Recommended) Add a tag to the cloned toolchain:\n', `cloned-from-${srcRegion}`, validateTagOrEmpty);
173
+ }
174
+ }
175
+ return [newTcName, newTag];
176
+ } else {
177
+ return [tcName, targetTag];
178
+ }
179
+ } else {
180
+ return [tcName, targetTag];
181
+ }
182
+ }
183
+
184
+ async function validateTools(token, tcId, region, skipPrompt) {
185
+ const allTools = await getToolchainTools(token, tcId, region);
186
+ const nonConfiguredTools = [];
187
+ const toolsWithHashedParams = [];
188
+ const patTools = [];
189
+ const classicPipelines = [];
190
+ const secretPattern = /^hash:SHA3-512:[a-zA-Z0-9]{128}$/;
191
+
192
+ for (const tool of allTools.tools) {
193
+ const toolName = (tool.name || tool.parameters?.name || tool.parameters?.label || '').replace(/\s+/g, '+');
194
+ logger.updateSpinnerMsg(`Validating tool \'${toolName}\'`);
195
+ const toolUrl = `https://cloud.ibm.com/devops/toolchains/${tool.toolchain_id}/configure/${tool.id}?env_id=ibm:yp:${region}`;
196
+
197
+ if (tool.state !== 'configured') { // Check for tools in misconfigured/unconfigured/configuring state
198
+ nonConfiguredTools.push({
199
+ tool_name: toolName,
200
+ type: tool.tool_type_id,
201
+ state: tool.state,
202
+ url: toolUrl
203
+ });
204
+ } else {
205
+ // handle health check failures, which forces an "error" state in the UI
206
+ if (tool.tool_type_id === 'appconfig') {
207
+ try {
208
+ await getAppConfigHealthcheck(token, tcId, tool.id, region);
209
+ } catch {
210
+ nonConfiguredTools.push({
211
+ tool_name: toolName,
212
+ type: tool.tool_type_id,
213
+ state: 'error',
214
+ url: toolUrl
215
+ });
216
+ }
217
+ } else if (['hashicorpvault', 'secretsmanager', 'keyprotect'].includes(tool.tool_type_id)) {
218
+ try {
219
+ // secrets healthcheck uses parameter name
220
+ const paramName = tool.parameters?.name || '';
221
+ await getSecretsHealthcheck(token, tcId, paramName, region);
222
+ } catch {
223
+ nonConfiguredTools.push({
224
+ tool_name: toolName,
225
+ type: tool.tool_type_id,
226
+ state: 'error',
227
+ url: toolUrl
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ if (tool.tool_type_id === 'hostedgit' && tool.parameters?.auth_type === 'pat') { // Check for GRIT using PAT
234
+ patTools.push({
235
+ tool_name: toolName,
236
+ type: tool.tool_type_id,
237
+ url: toolUrl
238
+ });
239
+ }
240
+ else if (tool.tool_type_id === 'pipeline' && tool.parameters?.type === 'classic') { // Check for Classic pipelines
241
+ classicPipelines.push({
242
+ tool_name: toolName,
243
+ type: 'classic pipeline',
244
+ url: toolUrl
245
+ });
246
+ }
247
+ else if (['githubconsolidated', 'github_integrated', 'gitlab'].includes(tool.tool_type_id) && (tool.parameters?.auth_type === '' || tool.parameters?.auth_type === 'oauth')) { // Skip secret check iff it's GitHub/GitLab integration with OAuth
248
+ continue;
249
+ }
250
+ else {
251
+ const secrets = [];
252
+ if (tool.tool_type_id === 'pipeline' && tool.parameters?.type === 'tekton') { // Check for secrets in Tekton pipeline
253
+ const pipelineData = await getPipelineData(token, tool.id, region);
254
+
255
+ pipelineData.properties.forEach((prop) => {
256
+ if (prop.type === 'secure' && secretPattern.test(prop.value)) secrets.push(['properties', prop.name].join('.').replace(/\s+/g, '+'));
257
+ });
258
+
259
+ pipelineData.triggers.forEach((trigger) => {
260
+ if ((trigger?.secret?.type === 'token_matches' || trigger?.secret?.type === 'digest_matches') && secretPattern.test(trigger.secret.value)) secrets.push([trigger.name, trigger.secret.key_name].join('.').replace(/\s+/g, '+'));
261
+ trigger.properties.forEach((prop) => {
262
+ if (prop.type === 'secure' && secretPattern.test(prop.value)) secrets.push([trigger.name, 'properties', prop.name].join('.').replace(/\s+/g, '+'));
263
+ });
264
+ });
265
+ }
266
+ else {
267
+ const secretsToCheck = SECRETS_MAP[tool.tool_type_id] || []; // Check for secrets in the rest of the tools
268
+ Object.entries(tool.parameters).forEach(([key, value]) => {
269
+ if (secretPattern.test(value) && secretsToCheck.includes(key)) secrets.push(key);
270
+ });
271
+ }
272
+ if (secrets.length > 0) {
273
+ toolsWithHashedParams.push({
274
+ tool_name: toolName,
275
+ type: tool.tool_type_id,
276
+ secret_params: secrets,
277
+ url: toolUrl
278
+ });
279
+ }
280
+ }
281
+ }
282
+ const invalid = nonConfiguredTools.length > 0 || patTools.length > 0 || classicPipelines.length > 0 || toolsWithHashedParams.length > 0;
283
+
284
+ // Manually fail and reset spinner to prevent duplicate spinners
285
+ if (invalid) {
286
+ logger.failSpinner('Invalid tools found!');
287
+ logger.resetSpinner();
288
+ }
289
+
290
+ if (nonConfiguredTools.length > 0) {
291
+ logger.warn('Warning! The following tool(s) are not in configured state in toolchain, please reconfigure them before proceeding: \n', LOG_STAGES.setup, true);
292
+ logger.table(nonConfiguredTools);
293
+ }
294
+
295
+ if (patTools.length > 0) {
296
+ logger.warn('Warning! The following GRIT integration(s) are using auth_type "pat", please switch to auth_type "oauth" before proceeding: \n', LOG_STAGES.setup, true);
297
+ logger.table(patTools);
298
+ }
299
+
300
+ if (classicPipelines.length > 0) {
301
+ logger.warn('Warning! Classic pipelines are currently not supported in migration:\n', LOG_STAGES.setup, true);
302
+ logger.table(classicPipelines);
303
+ }
304
+
305
+ if (toolsWithHashedParams.length > 0) {
306
+ logger.warn('Warning! The following tools contain secrets that cannot be migrated, please use the \'check-secret\' command to export the secrets: \n', LOG_STAGES.setup, true);
307
+ logger.table(toolsWithHashedParams);
308
+ }
309
+
310
+ if (!skipPrompt && invalid) await promptUserConfirmation('Caution: The above tool(s) will not be properly configured post migration. Do you want to proceed?', 'yes', 'Toolchain migration cancelled.');
311
+
312
+ return allTools.tools;
313
+ }
314
+
315
+ async function validateOAuth(token, tools, targetRegion, skipPrompt) {
316
+ let gitTools = [];
317
+
318
+ for (const tool of tools) {
319
+ const toolName = tool.parameters?.label || tool.parameters?.name || tool.name;
320
+
321
+ let gitId = tool.parameters?.git_id;
322
+
323
+ // the following gets the authorize_url for each source, uniquely identifying GHE and non-GHE repos
324
+ if (tool.tool_type_id === 'githubconsolidated' || tool.tool_type_id === 'github_integrated') {
325
+ // GHE gets converted anyway, so include in GH case
326
+ if (tool.parameters?.auth_type != 'oauth' || !tool.parameters?.git_id) continue;
327
+ tool._sortId = gitId;
328
+ tool._label = toolName;
329
+ gitTools.push(tool);
330
+ } else if (tool.tool_type_id === 'gitlab') {
331
+ if (tool.parameters?.auth_type != 'oauth' || !tool.parameters?.git_id) continue;
332
+ tool._sortId = gitId;
333
+ tool._label = toolName;
334
+ gitTools.push(tool);
335
+ } else if (tool.tool_type_id === 'bitbucketgit') {
336
+ // has no auth_type
337
+ if (!tool.parameters?.git_id) continue;
338
+ tool._sortId = gitId;
339
+ tool._label = toolName;
340
+ gitTools.push(tool);
341
+ } else if (tool.tool_type_id === 'hostedgit') {
342
+ // in GRIT case, getGitOAuth will actually authorize automatically
343
+ if (tool.parameters?.auth_type != 'oauth' || !tool.parameters?.git_id) continue;
344
+ tool._sortId = gitId;
345
+ tool._label = toolName;
346
+ gitTools.push(tool);
347
+ }
348
+ }
349
+
350
+ // sort gitTools by _sortId = git_id (asc), then name (asc)
351
+ gitTools = gitTools.sort((a, b) => {
352
+ if (a._sortId < b._sortId) {
353
+ return -1;
354
+ } else if (a._sortId > b._sortId) {
355
+ return 1;
356
+ } else {
357
+ if (a._label < b._label) {
358
+ return -1;
359
+ } else if (a._label > b._label) {
360
+ return 1;
361
+ } else {
362
+ return 0
363
+ }
364
+ };
365
+ });
366
+
367
+ const failedOAuth = new Set();
368
+ const successfulOAuth = new Set();
369
+ const failedTools = [];
370
+ const oauthLinks = [];
371
+
372
+ for (const tool of gitTools) {
373
+ const isGHE = tool._sortId === 'integrated';
374
+
375
+ if (failedOAuth.has(tool._sortId)) {
376
+ // don't retry failed attempts
377
+ failedTools.push({
378
+ tool_name: tool._label,
379
+ type: tool.tool_type_id + (isGHE ? ' (GHE)' : ''),
380
+ link: '',
381
+ })
382
+ } else if (successfulOAuth.has(tool._sortId)) {
383
+ // don't retry successful attempts
384
+ } else {
385
+ try {
386
+ // attempt to get access token, meaning oauth was set up correctly
387
+ await getGitOAuth(token, targetRegion, tool.parameters?.git_id);
388
+ successfulOAuth.add(tool._sortId);
389
+ } catch (authorizeUrl) {
390
+ failedOAuth.add(tool._sortId);
391
+ failedTools.push({
392
+ tool_name: tool._label,
393
+ type: tool.tool_type_id + (isGHE ? ' (GHE)' : ''),
394
+ link: authorizeUrl?.message != 'Get git OAuth failed' ? 'See link below' : 'Get git OAuth failed',
395
+ })
396
+ if (authorizeUrl?.message != 'Get git OAuth failed') {
397
+ if (isGHE) {
398
+ oauthLinks.push({ type: 'githubconsolidated (GHE)', link: authorizeUrl?.message });
399
+ } else {
400
+ oauthLinks.push({ type: tool.tool_type_id, link: authorizeUrl?.message });
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ // Manually fail and reset spinner to prevent duplicate spinners
408
+ if (failedOAuth.size > 0) {
409
+ logger.failSpinner();
410
+ logger.resetSpinner();
411
+
412
+ logger.warn('Warning! The following git tool integration(s) are not authorized in the target region: \n', LOG_STAGES.setup, true);
413
+ logger.table(failedTools);
414
+
415
+ logger.print('Authorize using the following links: \n');
416
+ oauthLinks.forEach((o) => {
417
+ logger.print(`${o.type}: \x1b[34m${o.link}\x1b[0m\n`);
418
+ });
419
+
420
+ if (!skipPrompt) await promptUserConfirmation('Caution: The above git tool integration(s) will not be properly configured post migration. Do you want to proceed?', 'yes', 'Toolchain migration cancelled.');
421
+ }
422
+ }
423
+
424
+ async function validateGritUrl(token, region, url, validateFull) {
425
+ if (typeof url != 'string') throw Error('Provided GRIT url is not a string');
426
+ let trimmed;
427
+
428
+ if (validateFull) {
429
+ if (!url.startsWith(`https://${region}.git.cloud.ibm.com/`) || !url.endsWith('.git')) throw Error('Provided full GRIT url is not valid');
430
+ trimmed = url.slice(`https://${region}.git.cloud.ibm.com/`.length, url.length - '.git'.length);
431
+ } else {
432
+ trimmed = url.trim();
433
+ }
434
+
435
+ // split into two parts, user/group/subgroup and project
436
+ const urlSplit = trimmed.split('/');
437
+ if (urlSplit.length < 2) throw Error('Provided GRIT url is invalid (missing forward-slash)');
438
+
439
+ const projectName = urlSplit[urlSplit.length - 1];
440
+ const urlStart = trimmed.slice(0, trimmed.length - projectName.length - 1);
441
+
442
+ // check reserved names, see https://docs.gitlab.com/user/reserved_names/
443
+ if (RESERVED_GRIT_PROJECT_NAMES.includes(projectName)) throw Error('Provided GRIT url invalid (contains reserved name)');
444
+ if (RESERVED_GRIT_GROUP_NAMES.includes(urlStart)) throw Error('Provided GRIT url invalid (contains reserved name)');
445
+ if (urlStart.includes(RESERVED_GRIT_SUBGROUP_NAME)) throw Error('Provided GRIT url invalid (contains reserved name)');
446
+
447
+ // valid characters only, max length 255
448
+ const pattern1 = /^[a-zA-Z0-9-._]{0,255}$/;
449
+ const pattern1alt = /^[a-zA-Z0-9-._\/]{0,255}$/;
450
+
451
+ // starts and ends with alphanumeric
452
+ const pattern2 = /^[a-zA-Z0-9].*$/;
453
+ const pattern3 = /^.*[a-zA-Z0-9]$/;
454
+
455
+ // no consecutive special characters
456
+ const pattern4 = /^.*[-._\/]{2,}.*$/; // want false
457
+
458
+ if (!pattern1.test(projectName)) throw Error('Provided project contains illegal character(s)');
459
+ if (!pattern2.test(projectName)) throw Error('Provided project does not start with an alphanumeric character');
460
+ if (!pattern3.test(projectName)) throw Error('Provided project does not end with an alphanumeric character');
461
+ if (pattern4.test(projectName)) throw Error('Provided project contains consecutive special characters');
462
+ if (!pattern1alt.test(urlStart)) throw Error('Provided user/group contains illegal character(s)');
463
+ if (!pattern2.test(urlStart)) throw Error('Provided user/group does not start with an alphanumeric character');
464
+ if (!pattern3.test(urlStart)) throw Error('Provided user/group does not end with an alphanumeric character');
465
+ if (pattern4.test(urlStart)) throw Error('Provided user/group contains consecutive special characters');
466
+
467
+ // cannot end with .git or .atom
468
+ if (projectName.endsWith('.git') && !projectName.endsWith('.atom')) throw Error('Provided GRIT url contains .git or .atom suffix');
469
+ const accessToken = await getGitOAuth(token, region, 'hostedgit');
470
+
471
+ // validate using API
472
+ let hasFailed = false;
473
+
474
+ // try user
475
+ try {
476
+ await getGritUserProject(accessToken, region, urlStart, projectName);
477
+ } catch {
478
+ hasFailed = true;
479
+ }
480
+
481
+ if (!hasFailed) return trimmed;
482
+
483
+ // try group
484
+ try {
485
+ const groupId = await getGritGroup(accessToken, region, urlStart);
486
+ await getGritGroupProject(accessToken, region, groupId, projectName);
487
+ return trimmed;
488
+ } catch {
489
+ throw Error('Provided GRIT url not found');
490
+ }
491
+ }
492
+
493
+ export {
494
+ validatePrereqsVersions,
495
+ validateToolchainId,
496
+ validateToolchainName,
497
+ validateResourceGroupId,
498
+ validateTag,
499
+ validateTools,
500
+ validateOAuth,
501
+ validateGritUrl,
502
+ warnDuplicateName
503
+ }