@ibm-cloud/cd-tools 1.5.3 → 1.6.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.
@@ -22,25 +22,22 @@ import { Command, Option } from 'commander';
22
22
  import { parseEnvVar } from './utils/utils.js';
23
23
  import { logger, LOG_STAGES } from './utils/logger.js';
24
24
  import { setTerraformEnv, initProviderFile, setupTerraformFiles, runTerraformInit, getNumResourcesPlanned, runTerraformApply, getNumResourcesCreated, getNewToolchainId } from './utils/terraform.js';
25
- import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroupIdAndName, getToolchain } from './utils/requests.js';
25
+ import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroups, getToolchain } from './utils/requests.js';
26
26
  import { validatePrereqsVersions, validateTag, validateToolchainId, validateToolchainName, validateTools, validateOAuth, warnDuplicateName, validateGritUrl } from './utils/validate.js';
27
27
  import { importTerraform } from './utils/import-terraform.js';
28
28
 
29
- import { COPY_TOOLCHAIN_DESC, DOCS_URL, TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
29
+ import { COPY_TOOLCHAIN_DESC, TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
30
30
 
31
31
  import packageJson from '../package.json' with { type: "json" };
32
32
 
33
- process.on('exit', (code) => {
34
- if (code !== 0) logger.print(`Need help? Visit ${DOCS_URL} for more troubleshooting information.`);
35
- });
36
-
37
33
  const TIME_SUFFIX = new Date().getTime();
38
34
  const LOGS_DIR = '.logs';
39
35
  const TEMP_DIR = '.migration-temp-' + TIME_SUFFIX;
40
36
  const LOG_DUMP = process.env['LOG_DUMP'] === 'false' ? false : true; // when true or not specified, logs are also written to a log file in LOGS_DIR
41
- const DEBUG_MODE = process.env['DEBUG_MODE'] === 'true' ? true : false; // when true, temp folder is preserved
37
+ const DEBUG_MODE = process.env['DEBUG_MODE'] === 'true'; // when true, temp folder is preserved
42
38
  const OUTPUT_DIR = 'output-' + TIME_SUFFIX;
43
39
  const DRY_RUN = false; // when true, terraform apply does not run
40
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
44
41
 
45
42
 
46
43
  const command = new Command('copy-toolchain')
@@ -106,7 +103,7 @@ async function main(options) {
106
103
  // Validate arguments are valid and check if Terraform is installed appropriately
107
104
  try {
108
105
  validatePrereqsVersions();
109
- logger.info(`\x1b[32m✔\x1b[0m cd-tools Version: ${packageJson.version}`, LOG_STAGES.setup);
106
+ logger.info(`\x1b[32m✔\x1b[0m cd-tools Version: ${packageJson.version}`, LOG_STAGES.setup);
110
107
 
111
108
  if (!apiKey) apiKey = parseEnvVar('IBMCLOUD_API_KEY');
112
109
  bearer = await getBearerToken(apiKey);
@@ -163,8 +160,8 @@ async function main(options) {
163
160
  exit(1);
164
161
  }
165
162
 
166
- ({ id: targetRgId, name: targetRgName } = await getResourceGroupIdAndName(bearer, accountId, targetRg || sourceToolchainData['resource_group_id']));
167
-
163
+ const resourceGroups = await getResourceGroups(bearer, accountId, [targetRg || sourceToolchainData['resource_group_id']]);
164
+ ({ id: targetRgId, name: targetRgName } = resourceGroups[0])
168
165
  // reuse name if not provided
169
166
  if (!targetToolchainName) targetToolchainName = sourceToolchainData['name'];
170
167
  [targetToolchainName, targetTag] = await warnDuplicateName(bearer, accountId, targetToolchainName, sourceRegion, targetRegion, targetRgId, targetRgName, targetTag, skipUserConfirmation);
@@ -321,7 +318,7 @@ async function main(options) {
321
318
 
322
319
  // copy script
323
320
  const s2sScript = fs.readFileSync(resolve(__dirname, '../create-s2s-script.js'));
324
- fs.writeFileSync(resolve(`${outputDir}/create-s2s-script.js`), s2sScript);
321
+ fs.writeFileSync(resolve(`${outputDir}/create-s2s-script.cjs`), s2sScript);
325
322
  }
326
323
 
327
324
  // create toolchain, which invokes script to create s2s if applicable
@@ -341,7 +338,7 @@ async function main(options) {
341
338
 
342
339
  logger.print('\n');
343
340
  logger.info(`Toolchain "${sourceToolchainData['name']}" from ${sourceRegion} was cloned to "${targetToolchainName ?? sourceToolchainData['name']}" in ${targetRegion} ${applyErrors ? 'with some errors' : 'successfully'}, with ${numResourcesCreated} / ${numResourcesPlanned} resources created!`, LOG_STAGES.info);
344
- if (newTcId) logger.info(`See cloned toolchain: https://cloud.ibm.com/devops/toolchains/${newTcId}?env_id=ibm:yp:${targetRegion}`, LOG_STAGES.info, true);
341
+ if (newTcId) logger.info(`See cloned toolchain: https://${CLOUD_PLATFORM}/devops/toolchains/${newTcId}?env_id=ibm:yp:${targetRegion}`, LOG_STAGES.info, true);
345
342
  } else {
346
343
  logger.info(`DRY_RUN: ${dryRun}, skipping terraform apply...`, LOG_STAGES.tf);
347
344
  }
@@ -0,0 +1,323 @@
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
+ 'use strict';
10
+
11
+ import { exit } from 'node:process';
12
+ import { Command } from 'commander';
13
+ import { parseEnvVar, decomposeCrn, isSecretReference, promptUserSelection, promptUserYesNo, promptUserInput } from './utils/utils.js';
14
+ import { logger, LOG_STAGES } from './utils/logger.js';
15
+ import { getBearerToken, getToolchain, getToolchainTools, getPipelineData, getSmInstances, createTool, getAccountId, getResourceGroups, migrateToolchainSecrets } from './utils/requests.js';
16
+ import { SECRET_KEYS_MAP } from '../config.js';
17
+
18
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
19
+
20
+ const command = new Command('export-secrets')
21
+ .description('Exports Toolchain stored secrets to a Secrets Manager instance')
22
+ .requiredOption('-c, --toolchain-crn <crn>', 'The CRN of the toolchain to check')
23
+ .option('-a, --apikey <api_key>', 'API key used to authenticate. Must have IAM permission to read toolchains and create secrets in Secrets Manager')
24
+ .option('--check', '(Optional) Checks and lists any stored secrets in your toolchain')
25
+ .option('-v, --verbose', '(Optional) Increase log output')
26
+ .showHelpAfterError()
27
+ .hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
28
+ .action(main);
29
+
30
+ async function main(options) {
31
+ const toolchainCrn = options.toolchainCrn;
32
+ const verbosity = options.verbose ? 2 : 1;
33
+ const runMigration = !options.check;
34
+
35
+ logger.setVerbosity(verbosity);
36
+
37
+ let apiKey;
38
+ let bearer;
39
+ let accountId;
40
+ let toolchainId;
41
+ let region;
42
+ let toolchainData;
43
+ let tools;
44
+
45
+ try {
46
+ const toolResults = [];
47
+ const pipelineResults = [];
48
+
49
+ apiKey = options.apikey || parseEnvVar('IBMCLOUD_API_KEY');
50
+ if (!apiKey) {
51
+ logger.error('Missing IBM Cloud IAM API key', LOG_STAGES.setup);
52
+ exit(1);
53
+ };
54
+
55
+ try {
56
+ const decomposedCrn = decomposeCrn(toolchainCrn);
57
+ toolchainId = decomposedCrn.serviceInstance;
58
+ region = decomposedCrn.location;
59
+ } catch (e) {
60
+ throw Error('Provided toolchain CRN is invalid');
61
+ }
62
+
63
+ // Display toolchain data to user
64
+ const getToolchainData = async () => {
65
+ bearer = await getBearerToken(apiKey);
66
+ toolchainData = await getToolchain(bearer, toolchainId, region);
67
+ }
68
+ await logger.withSpinner(getToolchainData, `Reading Toolchain`, 'Valid Toolchain found!');
69
+ logger.print(`Name: ${toolchainData.name}\nRegion: ${region}\nResource Group ID: ${toolchainData.resource_group_id}\nURL:https://${CLOUD_PLATFORM}/devops/toolchains/${toolchainId}?env_id=ibm:yp:${region}\n`);
70
+
71
+ // Check for plain-text secrets in all tools
72
+ const exportSecrets = async () => {
73
+ const getToolsRes = await getToolchainTools(bearer, toolchainId, region);
74
+ tools = getToolsRes.tools;
75
+
76
+ if (tools.length > 0) {
77
+ for (let i = 0; i < tools.length; i++) {
78
+ const tool = tools[i];
79
+ const toolUrl = `https://${CLOUD_PLATFORM}/devops/toolchains/${tool.toolchain_id}/configure/${tool.id}?env_id=ibm:yp:${region}`;
80
+ const toolName = (tool.name || tool.parameters?.name || tool.parameters?.label || '').replace(/\s+/g, '+');
81
+
82
+ // Skip iff it's GitHub/GitLab/GRIT integration with OAuth
83
+ if (['githubconsolidated', 'github_integrated', 'gitlab', 'hostedgit'].includes(tool.tool_type_id) && (tool.parameters?.auth_type === '' || tool.parameters?.auth_type === 'oauth'))
84
+ continue;
85
+
86
+ // Check tool integrations for any plain text secret values
87
+ if (SECRET_KEYS_MAP[tool.tool_type_id]) {
88
+ SECRET_KEYS_MAP[tool.tool_type_id].forEach((entry) => {
89
+ const updateableSecretParam = entry.key;
90
+ if (tool.parameters[updateableSecretParam] && !isSecretReference(tool.parameters[updateableSecretParam]) && tool.parameters[updateableSecretParam].length > 0) {
91
+ toolResults.push({
92
+ 'Tool ID': tool.id,
93
+ 'Tool Name': toolName,
94
+ 'Tool Type': tool.tool_type_id,
95
+ 'Property Name': updateableSecretParam,
96
+ 'Url': toolUrl
97
+ });
98
+ };
99
+ });
100
+ };
101
+
102
+ // For tekton pipelines, check for any plain text secret properties
103
+ if (tool.tool_type_id === 'pipeline' && tool.parameters?.type === 'tekton') {
104
+ const pipelineBaseUrl = `https://${CLOUD_PLATFORM}/devops/pipelines/tekton/${tool.id}`
105
+ const pipelineData = await getPipelineData(bearer, tool.id, region);
106
+
107
+ pipelineData?.properties.forEach((prop) => {
108
+ if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
109
+ pipelineResults.push({
110
+ 'Pipeline ID': pipelineData.id,
111
+ 'Pipeline Name': toolName,
112
+ 'Trigger Name': '',
113
+ 'Property Name': prop.name,
114
+ 'Url': pipelineBaseUrl + `/config/envProperties?env_id=ibm:yp:${region}`,
115
+ });
116
+ };
117
+ });
118
+
119
+ pipelineData?.triggers.forEach((trigger) => {
120
+ trigger.properties?.forEach((prop) => {
121
+ if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
122
+ pipelineResults.push({
123
+ 'Pipeline ID': pipelineData.id,
124
+ 'Pipeline Name': toolName,
125
+ 'Trigger Name': trigger.name,
126
+ 'Trigger ID': trigger.id,
127
+ 'Property Name': prop.name,
128
+ 'Url': pipelineBaseUrl + `?env_id=ibm:yp:${region}`
129
+ });
130
+ };
131
+ });
132
+ });
133
+ }
134
+ };
135
+ };
136
+ };
137
+
138
+ await logger.withSpinner(exportSecrets, `Checking secrets for toolchain ${toolchainCrn}`, 'Secret check complete!');
139
+
140
+ const numTotalSecrets = toolResults.length + pipelineResults.length;
141
+ if (numTotalSecrets > 0) {
142
+ logger.warn(`\nNote: ${numTotalSecrets} locally stored secret(s) found!`)
143
+ } else {
144
+ logger.success('\nNo locally stored secrets found!');
145
+ }
146
+ if (toolResults.length > 0) {
147
+ logger.print();
148
+ logger.print('The following plain text properties were found in tool integrations bound to the toolchain:');
149
+ logger.table(toolResults, 'Url');
150
+ }
151
+ if (pipelineResults.length > 0) {
152
+ logger.print();
153
+ logger.print('The following plain text properties were found in Tekton pipeline(s) bound to the toolchain:');
154
+ logger.table(pipelineResults, 'Url', ['Trigger ID']);
155
+ }
156
+ if (numTotalSecrets > 0 && !runMigration) {
157
+ logger.warn(`\nNote: ${numTotalSecrets} locally stored secret(s) found!\nSecrets stored locally in Toolchains and Pipelines will not be exported when copying a toolchain. It is recommended that secrets be moved to a Secrets Manager instance and converted to secret references if these secrets are required.`);
158
+ logger.warn(`\nTo migrate secrets to Secrets Manager, ensure that you have provisioned an instance of Secrets Manager which you have write access to and rerun the command without the additional param '--check' to move the secrets into Secrets Manager.`)
159
+ }
160
+
161
+ // Facilitate Secrets Migration
162
+ const migrateSecrets = async () => {
163
+ accountId = await getAccountId(bearer, apiKey);
164
+
165
+ let allSmInstances = await getSmInstances(bearer, accountId);
166
+ if (allSmInstances.length === 0) {
167
+ logger.warn('No Secrets Manager instances found. Please create a Secrets Manager instance and try again.');
168
+ return;
169
+ }
170
+
171
+ const resourceGroups = await getResourceGroups(bearer, accountId, allSmInstances.map(inst => inst.resource_group_id));
172
+ const groupNameById = Object.fromEntries(
173
+ resourceGroups.map(g => [g.id, g.name])
174
+ );
175
+ allSmInstances = allSmInstances.map(inst => ({
176
+ ...inst,
177
+ resource_group_name: groupNameById[inst.resource_group_id] || 'Unknown'
178
+ }));
179
+
180
+ const instanceChoice = await promptUserSelection(
181
+ 'Select a Secrets Manager Instance to migrate secret(s) to:',
182
+ allSmInstances.map(inst => (`\n Name: ${inst.name} (${inst.id})\n Region: ${inst.region_id}\n Resource Group: ${inst.resource_group_name}`))
183
+ );
184
+ const smInstance = allSmInstances[instanceChoice];
185
+
186
+ // Check if there's an existing Secrets Manager tool integration
187
+ let hasSmIntegration = false;
188
+ for (const tool of tools) {
189
+ if (tool.state === 'configured' && tool.tool_type_id === 'secretsmanager') {
190
+ if (
191
+ (tool.parameters?.['instance-id-type'] === 'instance-name' && tool.parameters?.['instance-name'] === smInstance.name &&
192
+ tool.parameters?.region === smInstance.region_id && tool.parameters?.['resource-group'] === smInstance.resource_group_name) ||
193
+ (tool.parameters?.['instance-id-type'] === 'instance-crn' && tool.parameters?.['instance-crn'] === smInstance.crn)
194
+ ) {
195
+ hasSmIntegration = true;
196
+ break;
197
+ }
198
+ }
199
+ }
200
+
201
+ // Prompt user to create a Secrets Manager tool integration if it doesn't already exist
202
+ if (!hasSmIntegration) {
203
+ logger.warn('No valid Secrets Manager tool integration found.');
204
+ const toCreateSmTool = await promptUserYesNo(`Create a Secrets Manager tool integration?`);
205
+ if (!toCreateSmTool) {
206
+ logger.warn('Toolchain secrets will not be migrated to Secrets Manager. Please create a Secrets Manager tool integration and try again.');
207
+ return;
208
+ }
209
+ const smToolName = await promptUserInput(`Enter the name of the Secrets Manager tool integration to create [Press 'enter' to skip]: `, '', async (input) => {
210
+ if (input.length > 128) {
211
+ throw new Error('The tool integration name must be between 0 and 128 characters long.');
212
+ }
213
+ // from https://cloud.ibm.com/apidocs/toolchain#create-tool
214
+ else if (input !== '' && !/^([^\x00-\x7F]|[a-zA-Z0-9-._ ])+$/.test(input)) {
215
+ throw new Error('Provided tool integration name contains invalid characters.');
216
+ }
217
+ });
218
+
219
+ const smToolParams = {
220
+ 'tool_type_id': 'secretsmanager',
221
+ 'parameters': {
222
+ 'name': smToolName || 'Secrets Manager',
223
+ 'instance-id-type': 'instance-crn',
224
+ 'instance-crn': smInstance.crn,
225
+ }
226
+ };
227
+ try {
228
+ const smTool = await createTool(bearer, toolchainId, region, smToolParams);
229
+ logger.success(`Secrets Manager tool integration created: ${smTool.parameters.name} (${smTool.id})`);
230
+ const smToolUrl = `https://${CLOUD_PLATFORM}/devops/toolchains/${toolchainId}/configure/${smTool.id}?env_id=ibm:yp:${region}`;
231
+ logger.warn(`Create necessary IAM service authorization for toolchain to access Secrets Manager service instance:\n${smToolUrl}`);
232
+ } catch (e) {
233
+ logger.error(`Failed to create Secrets Manager tool integration: ${e.message}`);
234
+ throw e;
235
+ }
236
+ }
237
+
238
+ let numSecretsMigrated = 0;
239
+ const allSecrets = toolResults.concat(pipelineResults);
240
+ for (let i = 0; i < allSecrets.length; i++) {
241
+ logger.print('-------');
242
+ const secret = allSecrets[i];
243
+ const toolName = secret['Tool Name'] || secret['Pipeline Name'];
244
+ const toolType = secret['Tool Type'] || 'pipeline';
245
+ const toolId = secret['Tool ID'] || secret['Pipeline ID'];
246
+ const triggerId = secret['Trigger ID'];
247
+ const triggerName = secret['Trigger Name'];
248
+ const toolSecretKey = secret['Property Name'];
249
+ const toolSecretUrl = secret['Url'];
250
+ const secretPath = `${toolName || toolType}.${triggerName ? triggerName + '.' : ''}${toolSecretKey}`;
251
+
252
+ logger.print(`[${i + 1}]\n Tool integration: ${toolName ? `'${toolName}' (${toolType})` : toolType}\n Property: '${triggerName ? triggerName + '.' : ''}${toolSecretKey}'\n URL: ${toolSecretUrl}\n`);
253
+
254
+ const shouldMigrateSecret = await promptUserYesNo(`Migrate this secret to Secrets Manager instance '${smInstance.name}'?`);
255
+ if (!shouldMigrateSecret) {
256
+ continue;
257
+ }
258
+
259
+ const smSecretName = await promptUserInput(`Enter the name of the secret to create [${secretPath}]: `, '', async (input) => {
260
+ if (input.length < 2 || input.length > 256) {
261
+ throw new Error('The secret name must be between 2 and 256 characters long.');
262
+ }
263
+ // from https://cloud.ibm.com/apidocs/secrets-manager/secrets-manager-v2#create-secret
264
+ else if (!/^[A-Za-z0-9_][A-Za-z0-9_]*(?:_*-*\.*[A-Za-z0-9]*)*[A-Za-z0-9]+$/.test(input)) {
265
+ throw new Error('Provided secret name contains invalid characters.');
266
+ }
267
+ });
268
+
269
+ const smSecretGroupId = await promptUserInput(`Enter the ID of the secret group to create secret '${smSecretName}' in: `, 'default', async (input) => {
270
+ if (input.length < 7 || input.length > 36) {
271
+ throw new Error('The secret group name must be between 7 and 36 characters long.');
272
+ }
273
+ // from https://cloud.ibm.com/apidocs/secrets-manager/secrets-manager-v2#create-secret
274
+ else if (!/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|default)$/.test(input)) {
275
+ throw new Error('Provided secret group name is invalid. It should be a UUID or the word \'default\'');
276
+ }
277
+ });
278
+
279
+ try {
280
+ const commonProps = {
281
+ toolchain_id: toolchainId,
282
+ destination: {
283
+ is_private: false, // TODO: set this back to 'true' once 'otc-api' has the 'export_secret' endpoint, should always use SM private endpoint
284
+ is_production: CLOUD_PLATFORM === 'cloud.ibm.com',
285
+ secrets_manager_crn: smInstance.crn,
286
+ secret_name: smSecretName,
287
+ secret_group_id: smSecretGroupId
288
+ }
289
+ };
290
+ const payload = {
291
+ source: {
292
+ type: toolType === 'pipeline' ? toolType : 'tool',
293
+ id: toolType === 'pipeline' ? (triggerId || toolId) : toolId,
294
+ secret_key: toolSecretKey,
295
+ kind: toolType === 'pipeline' ? (triggerId ? 'trigger' : 'env') : undefined,
296
+ parent_id: toolType === 'pipeline' ? (triggerId ? toolId : undefined) : undefined
297
+ },
298
+ ...commonProps
299
+ };
300
+
301
+ const smSecretUrl = await migrateToolchainSecrets(bearer, payload, region);
302
+ logger.success(`Secret successfully migrated!\nSecret URL: ${smSecretUrl}`);
303
+ numSecretsMigrated += 1;
304
+ }
305
+ catch (e) {
306
+ logger.error(`Failed to migrate secret '${secretPath}'. Error message: ${e.message}`, '', true);
307
+ }
308
+ }
309
+ logger.success(`Toolchain secrets migration complete, ${numSecretsMigrated} secret(s) successfully migrated.`);
310
+ };
311
+
312
+ if (numTotalSecrets > 0 && runMigration) await migrateSecrets();
313
+ }
314
+ catch (err) {
315
+ if (err.message && err.stack) {
316
+ const errMsg = verbosity > 1 ? err.stack : err.message;
317
+ logger.error(errMsg);
318
+ }
319
+ exit(1);
320
+ }
321
+ };
322
+
323
+ export default command;
package/cmd/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Contract with IBM Corp.
8
8
  */
9
9
 
10
- import checkSecrets from './check-secrets.js';
10
+ import exportSecrets from './export-secrets.js';
11
11
  import copyToolchain from './copy-toolchain.js';
12
12
  import directTransfer from './direct-transfer.js';
13
- export { checkSecrets, copyToolchain, directTransfer };
13
+ export { exportSecrets, copyToolchain, directTransfer };
@@ -60,9 +60,9 @@ class Logger {
60
60
 
61
61
  #baseLog(type, msg, prefix) {
62
62
  const level = LEVELS[type] || LEVELS.log;
63
- const formatted = this.#getFullPrefix(prefix) + ' ' + `${level.color}${msg}${COLORS.reset}`;
63
+ const formatted = (prefix ? this.#getFullPrefix(prefix) + ' ' : '') + `${level.color}${msg}${COLORS.reset}`;
64
64
  console[level.method](formatted);
65
- this.logStream?.write(stripAnsi(this.#getFullPrefix(prefix) + ` [${type.toUpperCase()}] ` + msg) + '\n');
65
+ this.logStream?.write(stripAnsi((prefix ? this.#getFullPrefix(prefix) + ' ' : '') + `[${type.toUpperCase()}] ` + msg) + '\n');
66
66
  }
67
67
 
68
68
  info(msg, prefix = '', force = false) { if (this.verbosity >= 1 || force) this.#baseLog('info', msg, prefix); }
@@ -132,10 +132,10 @@ class Logger {
132
132
  return res;
133
133
  }
134
134
 
135
- table(data, rowSpanField = 'url') {
135
+ table(data, rowSpanField = 'url', colsToSkip = []) {
136
136
  if (!Array.isArray(data) || data.length < 1) return;
137
137
  const tableData = structuredClone(data);
138
- const headers = Object.keys(tableData[0]).filter(key => key !== rowSpanField);
138
+ const headers = Object.keys(tableData[0]).filter(key => key !== rowSpanField && !colsToSkip.includes(key));
139
139
  const t = new Table({
140
140
  head: headers,
141
141
  style: { head: ['cyan'] }
@@ -151,13 +151,18 @@ class Logger {
151
151
  delete row[rowKey];
152
152
  }
153
153
  tableRow.push(
154
- ...Object.values(row).map(val => {
155
- if (Array.isArray(val))
156
- return val.map((item, idx) => `${idx + 1}: ${item}`).join('\n');
157
- else if (typeof val === 'string')
158
- return val;
159
- return JSON.stringify(val);
160
- })
154
+ ...Object.entries(row)
155
+ .filter(([key]) => !colsToSkip.includes(key))
156
+ .map(([_, val]) => {
157
+ if (Array.isArray(val))
158
+ return val
159
+ .map((item, idx) => `${idx + 1}: ${item ?? '-'}`).join('\n');
160
+ else if (val === '')
161
+ return '-';
162
+ else if (typeof val === 'string')
163
+ return val;
164
+ return JSON.stringify(val);
165
+ })
161
166
  );
162
167
  t.push(tableRow);
163
168
  if (rowSpanFieldVal !== '') t.push([{ content: rowSpanFieldVal, colSpan: headers.length - 1 }]);
@@ -172,5 +177,6 @@ export const LOG_STAGES = {
172
177
  setup: 'setup',
173
178
  import: 'import',
174
179
  tf: 'terraform',
175
- info: 'info'
180
+ info: 'info',
181
+ request: 'request'
176
182
  };
@@ -13,6 +13,16 @@ import axiosRetry from 'axios-retry';
13
13
  import mocks from '../../test/data/mocks.js'
14
14
  import { logger, LOG_STAGES } from './logger.js';
15
15
 
16
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
17
+ const DEV_MODE = CLOUD_PLATFORM !== 'cloud.ibm.com';
18
+ const IAM_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_IAM_API_ENDPOINT'] : 'https://iam.cloud.ibm.com';
19
+ const GHOST_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_GS_API_ENDPOINT'] : 'https://api.global-search-tagging.cloud.ibm.com';
20
+ const DEVOPS_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_DEVOPS_URL'] : 'https://cloud.ibm.com/devops';
21
+ const TOOLCHAIN_BASE_ENDPOINT = DEV_MODE ? process.env['IBMCLOUD_TOOLCHAIN_ENDPOINT'] : '';
22
+ const PIPELINE_BASE_ENDPOINT = DEV_MODE ? process.env['IBMCLOUD_TEKTON_PIPELINE_ENDPOINT'] : '';
23
+ const GIT_BASE_ENDPOINT = DEV_MODE ? process.env['IBMCLOUD_GIT_ENDPOINT'] : '';
24
+ const OTC_BASE_ENDPOINT = DEV_MODE ? process.env['IBMCLOUD_OTC_ENDPOINT'] : '';
25
+
16
26
  const MOCK_ALL_REQUESTS = process.env.MOCK_ALL_REQUESTS === 'true' || 'false';
17
27
 
18
28
  axiosRetry(axios, {
@@ -23,13 +33,15 @@ axiosRetry(axios, {
23
33
  },
24
34
  });
25
35
 
36
+ axios.defaults.timeout = 10000; // 10 seconds
37
+
26
38
  axios.interceptors.request.use(request => {
27
- logger.debug(`${request.method.toUpperCase()} ${request.url}`, LOG_STAGES.setup);
39
+ logger.debug(`${request.method.toUpperCase()} ${request.url}`, LOG_STAGES.request);
28
40
  if (request.data) {
29
41
  const body = typeof request.data === 'string'
30
42
  ? request.data
31
43
  : JSON.stringify(request.data);
32
- logger.log(`Https Request body: ${body}`, LOG_STAGES.setup);
44
+ logger.log(`Https Request body: ${body}`, LOG_STAGES.request);
33
45
  }
34
46
  return request;
35
47
  });
@@ -41,21 +53,21 @@ axios.interceptors.response.use(response => {
41
53
  : JSON.stringify(response.data);
42
54
  if (response.data.access_token) // Redact user access token in logs
43
55
  body = body.replaceAll(response.data.access_token, '<USER ACCESS TOKEN>');
44
- logger.log(`Https Response body: ${body}`, LOG_STAGES.setup);
56
+ logger.log(`Https Response body: ${body}`, LOG_STAGES.request);
45
57
  }
46
58
  return response;
47
59
  }, error => {
48
60
  if (error.response) {
49
- logger.log(`Error response status: ${error.response.status} ${error.response.statusText}`);
50
- logger.log(`Error response body: ${JSON.stringify(error.response.data)}`);
61
+ logger.log(`Error response status: ${error.response.status} ${error.response.statusText}`, LOG_STAGES.request);
62
+ logger.log(`Error response body: ${JSON.stringify(error.response.data)}`, LOG_STAGES.request);
51
63
  } else {
52
- logger.log(`Error message: ${error.message}`);
64
+ logger.log(`Error message: ${error.message}`, LOG_STAGES.request);
53
65
  }
54
66
  return Promise.reject(error);
55
67
  });
56
68
 
57
69
  async function getBearerToken(apiKey) {
58
- const iamUrl = 'https://iam.cloud.ibm.com/identity/token';
70
+ const iamUrl = IAM_BASE_URL + '/identity/token';
59
71
  const params = new URLSearchParams();
60
72
  params.append('grant_type', 'urn:ibm:params:oauth:grant-type:apikey');
61
73
  params.append('apikey', apiKey);
@@ -78,7 +90,7 @@ async function getBearerToken(apiKey) {
78
90
  }
79
91
 
80
92
  async function getAccountId(bearer, apiKey) {
81
- const iamUrl = 'https://iam.cloud.ibm.com/v1/apikeys/details';
93
+ const iamUrl = IAM_BASE_URL + '/v1/apikeys/details';
82
94
  const options = {
83
95
  method: 'GET',
84
96
  url: iamUrl,
@@ -97,7 +109,7 @@ async function getAccountId(bearer, apiKey) {
97
109
  }
98
110
 
99
111
  async function getToolchain(bearer, toolchainId, region) {
100
- const apiBaseUrl = `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
112
+ const apiBaseUrl = TOOLCHAIN_BASE_ENDPOINT || `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
101
113
  const options = {
102
114
  method: 'GET',
103
115
  url: `${apiBaseUrl}/toolchains/${toolchainId}`,
@@ -120,9 +132,8 @@ async function getToolchain(bearer, toolchainId, region) {
120
132
  }
121
133
 
122
134
  async function getToolchainsByName(bearer, accountId, toolchainName) {
123
- const apiBaseUrl = 'https://api.global-search-tagging.cloud.ibm.com/v3';
124
135
  const options = {
125
- url: apiBaseUrl + '/resources/search',
136
+ url: GHOST_BASE_URL + '/v3/resources/search',
126
137
  method: 'POST',
127
138
  headers: {
128
139
  'Authorization': `Bearer ${bearer}`,
@@ -149,9 +160,8 @@ async function getCdInstanceByRegion(bearer, accountId, region) {
149
160
  return mocks.getCdInstanceByRegionResponses[process.env.MOCK_GET_CD_INSTANCE_BY_REGION_SCENARIO].data.items.length > 0;
150
161
  }
151
162
 
152
- const apiBaseUrl = 'https://api.global-search-tagging.cloud.ibm.com/v3';
153
163
  const options = {
154
- url: apiBaseUrl + '/resources/search',
164
+ url: GHOST_BASE_URL + '/v3/resources/search',
155
165
  method: 'POST',
156
166
  headers: {
157
167
  'Authorization': `Bearer ${bearer}`,
@@ -174,7 +184,7 @@ async function getCdInstanceByRegion(bearer, accountId, region) {
174
184
  }
175
185
 
176
186
  async function getToolchainTools(bearer, toolchainId, region) {
177
- const apiBaseUrl = `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
187
+ const apiBaseUrl = TOOLCHAIN_BASE_ENDPOINT || `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
178
188
  const options = {
179
189
  method: 'GET',
180
190
  url: `${apiBaseUrl}/toolchains/${toolchainId}/tools`,
@@ -196,7 +206,7 @@ async function getToolchainTools(bearer, toolchainId, region) {
196
206
  }
197
207
 
198
208
  async function getPipelineData(bearer, pipelineId, region) {
199
- const apiBaseUrl = `https://api.${region}.devops.cloud.ibm.com/pipeline/v2`;
209
+ const apiBaseUrl = PIPELINE_BASE_ENDPOINT || `https://api.${region}.devops.cloud.ibm.com/pipeline/v2`;
200
210
  const options = {
201
211
  method: 'GET',
202
212
  url: `${apiBaseUrl}/tekton_pipelines/${pipelineId}`,
@@ -216,18 +226,17 @@ async function getPipelineData(bearer, pipelineId, region) {
216
226
  }
217
227
  }
218
228
 
219
- // takes in resource group ID or name
220
- async function getResourceGroupIdAndName(bearer, accountId, resourceGroup) {
221
- const apiBaseUrl = 'https://api.global-search-tagging.cloud.ibm.com/v3';
229
+ // takes in list of resource group IDs or names
230
+ async function getResourceGroups(bearer, accountId, resourceGroups) {
222
231
  const options = {
223
- url: apiBaseUrl + '/resources/search',
232
+ url: GHOST_BASE_URL + '/v3/resources/search',
224
233
  method: 'POST',
225
234
  headers: {
226
235
  'Authorization': `Bearer ${bearer}`,
227
236
  'Content-Type': 'application/json',
228
237
  },
229
238
  data: {
230
- 'query': `type:resource-group AND (name:${resourceGroup} OR doc.id:${resourceGroup}) AND doc.state:ACTIVE`,
239
+ 'query': `type:resource-group AND doc.state:ACTIVE AND (${resourceGroups.map(rg => `name:${rg} OR doc.id:${rg}`).join(' OR ')})`,
231
240
  'fields': ['doc.id', 'doc.name']
232
241
  },
233
242
  params: { account_id: accountId },
@@ -236,17 +245,16 @@ async function getResourceGroupIdAndName(bearer, accountId, resourceGroup) {
236
245
  const response = await axios(options);
237
246
  switch (response.status) {
238
247
  case 200:
239
- if (response.data.items.length != 1) throw Error('The resource group with provided ID or name was not found or is not accessible');
240
- return { id: response.data.items[0].doc.id, name: response.data.items[0].doc.name };
248
+ if (response.data.items.length === 0) throw Error('No matching resource groups were found for the provided id(s) or name(s)');
249
+ return response.data.items.map(item => { return { id: item.doc.id, name: item.doc.name } });
241
250
  default:
242
- throw Error('The resource group with provided ID or name was not found or is not accessible');
251
+ throw Error('No matching resource groups were found for the provided id(s) or name(s)');
243
252
  }
244
253
  }
245
254
 
246
255
  async function getAppConfigHealthcheck(bearer, tcId, toolId, region) {
247
- const apiBaseUrl = 'https://cloud.ibm.com/devops/api/v1';
248
256
  const options = {
249
- url: apiBaseUrl + '/appconfig/healthcheck',
257
+ url: DEVOPS_BASE_URL + '/api/v1/appconfig/healthcheck',
250
258
  method: 'GET',
251
259
  headers: {
252
260
  'Authorization': `Bearer ${bearer}`,
@@ -265,9 +273,8 @@ async function getAppConfigHealthcheck(bearer, tcId, toolId, region) {
265
273
  }
266
274
 
267
275
  async function getSecretsHealthcheck(bearer, tcId, toolName, region) {
268
- const apiBaseUrl = 'https://cloud.ibm.com/devops/api/v1';
269
276
  const options = {
270
- url: apiBaseUrl + '/secrets/healthcheck',
277
+ url: DEVOPS_BASE_URL + '/api/v1/secrets/healthcheck',
271
278
  method: 'GET',
272
279
  headers: {
273
280
  'Authorization': `Bearer ${bearer}`,
@@ -286,15 +293,14 @@ async function getSecretsHealthcheck(bearer, tcId, toolName, region) {
286
293
  }
287
294
 
288
295
  async function getGitOAuth(bearer, targetRegion, gitId) {
289
- const url = 'https://cloud.ibm.com/devops/git/api/v1/tokens';
290
296
  const options = {
291
- url: url,
297
+ url: DEVOPS_BASE_URL + '/git/api/v1/tokens',
292
298
  method: 'GET',
293
299
  headers: {
294
300
  'Authorization': `Bearer ${bearer}`,
295
301
  'Content-Type': 'application/json',
296
302
  },
297
- params: { env_id: `ibm:yp:${targetRegion}`, git_id: gitId, console_url: 'https://cloud.ibm.com', return_uri: `https://cloud.ibm.com/devops/git/static/github_return.html` },
303
+ params: { env_id: `ibm:yp:${targetRegion}`, git_id: gitId, console_url: `https://${CLOUD_PLATFORM}`, return_uri: `https://${CLOUD_PLATFORM}/devops/git/static/github_return.html` },
298
304
  validateStatus: () => true
299
305
  };
300
306
  const response = await axios(options);
@@ -309,9 +315,9 @@ async function getGitOAuth(bearer, targetRegion, gitId) {
309
315
  }
310
316
 
311
317
  async function getGritUserProject(privToken, region, user, projectName) {
312
- const url = `https://${region}.git.cloud.ibm.com/api/v4/users/${user}/projects`
318
+ const apiBaseUrl = GIT_BASE_ENDPOINT || `https://${region}.git.cloud.ibm.com/api/v4`;
313
319
  const options = {
314
- url: url,
320
+ url: apiBaseUrl + `/users/${user}/projects`,
315
321
  method: 'GET',
316
322
  headers: {
317
323
  'PRIVATE-TOKEN': privToken
@@ -331,9 +337,9 @@ async function getGritUserProject(privToken, region, user, projectName) {
331
337
  }
332
338
 
333
339
  async function getGritGroup(privToken, region, groupName) {
334
- const url = `https://${region}.git.cloud.ibm.com/api/v4/groups/${groupName}`
340
+ const apiBaseUrl = GIT_BASE_ENDPOINT || `https://${region}.git.cloud.ibm.com/api/v4`;
335
341
  const options = {
336
- url: url,
342
+ url: apiBaseUrl + `/groups/${groupName}`,
337
343
  method: 'GET',
338
344
  headers: {
339
345
  'PRIVATE-TOKEN': privToken
@@ -352,9 +358,9 @@ async function getGritGroup(privToken, region, groupName) {
352
358
  }
353
359
 
354
360
  async function getGritGroupProject(privToken, region, groupId, projectName) {
355
- const url = `https://${region}.git.cloud.ibm.com/api/v4/groups/${groupId}/projects`
361
+ const apiBaseUrl = GIT_BASE_ENDPOINT || `https://${region}.git.cloud.ibm.com/api/v4`;
356
362
  const options = {
357
- url: url,
363
+ url: apiBaseUrl + `/groups/${groupId}/projects`,
358
364
  method: 'GET',
359
365
  headers: {
360
366
  'PRIVATE-TOKEN': privToken
@@ -374,7 +380,7 @@ async function getGritGroupProject(privToken, region, groupId, projectName) {
374
380
  }
375
381
 
376
382
  async function deleteToolchain(bearer, toolchainId, region) {
377
- const apiBaseUrl = `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
383
+ const apiBaseUrl = TOOLCHAIN_BASE_ENDPOINT || `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
378
384
  const options = {
379
385
  method: 'DELETE',
380
386
  url: `${apiBaseUrl}/toolchains/${toolchainId}`,
@@ -394,6 +400,83 @@ async function deleteToolchain(bearer, toolchainId, region) {
394
400
  }
395
401
  }
396
402
 
403
+ async function getSmInstances(bearer, accountId) {
404
+ const options = {
405
+ url: GHOST_BASE_URL + '/v3/resources/search',
406
+ method: 'POST',
407
+ headers: {
408
+ 'Authorization': `Bearer ${bearer}`,
409
+ 'Content-Type': 'application/json',
410
+ },
411
+ data: {
412
+ 'query': `service_name:secrets-manager AND doc.state:ACTIVE`,
413
+ 'fields': ['doc.resource_group_id', 'doc.region_id', 'doc.dashboard_url', 'doc.name', 'doc.guid']
414
+ },
415
+ params: { account_id: accountId },
416
+ validateStatus: () => true
417
+ };
418
+ const response = await axios(options);
419
+ switch (response.status) {
420
+ case 200:
421
+ return response.data.items.map(item => {
422
+ return {
423
+ id: item.doc.guid,
424
+ crn: item.crn,
425
+ name: item.doc.name,
426
+ resource_group_id: item.doc.resource_group_id,
427
+ region_id: item.doc.region_id,
428
+ dashboard_url: item.doc.dashboard_url
429
+ }
430
+ });
431
+ default:
432
+ throw Error('Get Secrets Manager instances failed');
433
+ }
434
+ }
435
+
436
+ async function createTool(bearer, toolchainId, region, params) {
437
+ const apiBaseUrl = TOOLCHAIN_BASE_ENDPOINT || `https://api.${region}.devops.cloud.ibm.com/toolchain/v2`;
438
+ const options = {
439
+ method: 'POST',
440
+ url: `${apiBaseUrl}/toolchains/${toolchainId}/tools`,
441
+ headers: {
442
+ 'Accept': 'application/json',
443
+ 'Authorization': `Bearer ${bearer}`,
444
+ 'Content-Type': 'application/json',
445
+ },
446
+ data: params,
447
+ validateStatus: () => true
448
+ };
449
+ const response = await axios(options);
450
+ switch (response.status) {
451
+ case 201:
452
+ return response.data;
453
+ default:
454
+ throw Error(response.statusText);
455
+ }
456
+ }
457
+
458
+ async function migrateToolchainSecrets(bearer, data, region) {
459
+ const apiBaseUrl = DEV_MODE ? OTC_BASE_ENDPOINT : `https://otc-api.${region}.devops.cloud.ibm.com/api/v1`;
460
+ const options = {
461
+ method: 'POST',
462
+ url: `${apiBaseUrl}/export_secret`,
463
+ headers: {
464
+ 'Accept': 'application/json',
465
+ 'Authorization': `Bearer ${bearer}`,
466
+ 'Content-Type': 'application/json',
467
+ },
468
+ data: data,
469
+ validateStatus: () => true
470
+ };
471
+ const response = await axios(options);
472
+ switch (response.status) {
473
+ case 201:
474
+ return response.headers.location;
475
+ default:
476
+ throw Error(response.data?.errors.length > 0 ? response.data.errors[0]?.message : response.statusText);
477
+ }
478
+ }
479
+
397
480
  export {
398
481
  getBearerToken,
399
482
  getAccountId,
@@ -402,12 +485,15 @@ export {
402
485
  getToolchainsByName,
403
486
  getToolchainTools,
404
487
  getPipelineData,
405
- getResourceGroupIdAndName,
488
+ getResourceGroups,
406
489
  getAppConfigHealthcheck,
407
490
  getSecretsHealthcheck,
408
491
  getGitOAuth,
409
492
  getGritUserProject,
410
493
  getGritGroup,
411
494
  getGritGroupProject,
412
- deleteToolchain
495
+ deleteToolchain,
496
+ createTool,
497
+ getSmInstances,
498
+ migrateToolchainSecrets
413
499
  }
@@ -18,6 +18,11 @@ import { validateToolchainId, validateGritUrl } from './validate.js';
18
18
  import { logger, LOG_STAGES } from './logger.js';
19
19
  import { getRandChars, promptUserInput, replaceUrlRegion } from './utils.js';
20
20
 
21
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
22
+ const DEV_MODE = CLOUD_PLATFORM !== 'cloud.ibm.com';
23
+ const GIT_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_GIT_URL'] : '';
24
+ const IAM_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_IAM_API_ENDPOINT'] : 'https://iam.cloud.ibm.com';
25
+
21
26
  // promisify
22
27
  const readFilePromise = promisify(fs.readFile);
23
28
  const readDirPromise = promisify(fs.readdir);
@@ -36,6 +41,19 @@ async function execPromise(command, options) {
36
41
  function setTerraformEnv(apiKey, verbosity) {
37
42
  if (verbosity >= 2) process.env['TF_LOG'] = 'DEBUG';
38
43
  process.env['TF_VAR_ibmcloud_api_key'] = apiKey;
44
+ // reset all Terraform environment variables if pointing to prod domain
45
+ if (!DEV_MODE) {
46
+ delete process.env['IBMCLOUD_TOOLCHAIN_ENDPOINT'];
47
+ delete process.env['IBMCLOUD_TEKTON_PIPELINE_ENDPOINT'];
48
+ delete process.env['IBMCLOUD_IAM_API_ENDPOINT'];
49
+ delete process.env['IBMCLOUD_USER_MANAGEMENT_ENDPOINT'];
50
+ delete process.env['IBMCLOUD_RESOURCE_MANAGEMENT_API_ENDPOINT'];
51
+ delete process.env['IBMCLOUD_RESOURCE_CONTROLLER_API_ENDPOINT'];
52
+ delete process.env['IBMCLOUD_IS_NG_API_ENDPOINT'];
53
+ delete process.env['IBMCLOUD_GT_API_ENDPOINT'];
54
+ delete process.env['IBMCLOUD_GS_API_ENDPOINT'];
55
+ delete process.env['IBMCLOUD_USER_MANAGEMENT_ENDPOINT'];
56
+ }
39
57
  }
40
58
 
41
59
  async function initProviderFile(targetRegion, dir) {
@@ -177,7 +195,7 @@ async function setupTerraformFiles({ token, srcRegion, targetRegion, targetTag,
177
195
  logger.print('Skipping... (URL will remain unchanged in the generatedTerraform configuration)');
178
196
  return '';
179
197
  }
180
- const newUrl = `https://${targetRegion}.git.cloud.ibm.com/${str}.git`;
198
+ const newUrl = (GIT_BASE_URL || `https://${targetRegion}.git.cloud.ibm.com`) + `/${str}.git`;
181
199
  if (usedGritUrls.has(newUrl)) throw Error(`"${newUrl}" has already been used in another mapping entry`);
182
200
  return validateGritUrl(token, targetRegion, str, false);
183
201
  }
@@ -187,10 +205,10 @@ async function setupTerraformFiles({ token, srcRegion, targetRegion, targetTag,
187
205
  logger.print('Please enter the new URLs for the following GRIT tool(s) (or submit empty input to skip):\n');
188
206
  }
189
207
 
190
- const newRepoSlug = await promptUserInput(`Old URL: ${thisUrl.slice(0, thisUrl.length - 4)}\nNew URL: https://${targetRegion}.git.cloud.ibm.com/`, '', validateGritUrlPrompt);
208
+ const newRepoSlug = await promptUserInput(`Old URL: ${thisUrl.slice(0, thisUrl.length - 4)}\nNew URL: ${GIT_BASE_URL || 'https://' + targetRegion + '.git.cloud.ibm.com'}`, '', validateGritUrlPrompt);
191
209
 
192
210
  if (newRepoSlug) {
193
- newUrl = `https://${targetRegion}.git.cloud.ibm.com/${newRepoSlug}.git`;
211
+ newUrl = (GIT_BASE_URL || `https://${targetRegion}.git.cloud.ibm.com`) + `/${newRepoSlug}.git`;
194
212
  newTfFileObj['resource']['ibm_cd_toolchain_tool_hostedgit'][k]['initialization'][0]['repo_url'] = newUrl;
195
213
  attemptAddUsedGritUrl(newUrl);
196
214
  gritMapping[thisUrl] = newUrl;
@@ -473,10 +491,12 @@ function replaceDependsOn(str) {
473
491
 
474
492
  function addS2sScriptToToolchainTf(str) {
475
493
  const provisionerStr = (tfName) => `\n\n provisioner "local-exec" {
476
- command = "node create-s2s-script.js"
494
+ command = "node create-s2s-script.cjs"
477
495
  environment = {
478
496
  IBMCLOUD_API_KEY = var.ibmcloud_api_key
479
497
  TARGET_TOOLCHAIN_ID = ibm_cd_toolchain.${tfName}.id
498
+ IBMCLOUD_PLATFORM = "${CLOUD_PLATFORM}"
499
+ IAM_BASE_URL = "${IAM_BASE_URL}"
480
500
  }\n }`
481
501
  try {
482
502
  if (typeof str === 'string') {
@@ -21,6 +21,32 @@ export function parseEnvVar(name) {
21
21
  return value;
22
22
  };
23
23
 
24
+ export async function promptUserYesNo(question) {
25
+ const rl = readline.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout
28
+ });
29
+
30
+ const fullPrompt = `${question} [Ctrl-C to abort] (y)es (n)o: `;
31
+
32
+ const answer = await rl.question(fullPrompt);
33
+
34
+ logger.dump(fullPrompt + '\n' + answer + '\n');
35
+ rl.close();
36
+
37
+ const normalized = answer.toLowerCase().trim();
38
+ if (normalized === 'y' || normalized === 'yes') {
39
+ logger.print();
40
+ return true;
41
+ }
42
+ if (normalized === 'n' || normalized === 'no') {
43
+ logger.print();
44
+ return false;
45
+ }
46
+ logger.warn('\nInvalid input. Please enter \'y\' or \'n\'.');
47
+ return await promptUserYesNo(question);
48
+ }
49
+
24
50
  export async function promptUserConfirmation(question, expectedAns, exitMsg) {
25
51
  const rl = readline.createInterface({
26
52
  input: process.stdin,
@@ -33,7 +59,7 @@ export async function promptUserConfirmation(question, expectedAns, exitMsg) {
33
59
  logger.dump(fullPrompt + '\n' + answer + '\n');
34
60
 
35
61
  if (answer.toLowerCase().trim() !== expectedAns) {
36
- logger.print('\n' + exitMsg);
62
+ if (exitMsg) logger.print('\n' + exitMsg);
37
63
  rl.close();
38
64
  await logger.close();
39
65
  process.exit(1);
@@ -59,7 +85,7 @@ export async function promptUserInput(question, initialInput, validationFn) {
59
85
  });
60
86
 
61
87
  rl.prompt(true);
62
- rl.write(initialInput);
88
+ if (initialInput) rl.write(initialInput);
63
89
 
64
90
  for await (const ans of rl) {
65
91
  try {
@@ -72,7 +98,7 @@ export async function promptUserInput(question, initialInput, validationFn) {
72
98
  logger.warn(`Validation failed... ${e.message}`, '', true);
73
99
 
74
100
  rl.prompt(true);
75
- rl.write(initialInput);
101
+ if (initialInput) rl.write(initialInput);
76
102
  }
77
103
  }
78
104
 
@@ -81,6 +107,44 @@ export async function promptUserInput(question, initialInput, validationFn) {
81
107
  return answer.trim();
82
108
  }
83
109
 
110
+ export async function promptUserSelection(question, choices) {
111
+ const rl = readline.createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout
114
+ });
115
+
116
+ rl.on('SIGINT', async () => {
117
+ logger.print('\n' + 'Received SIGINT signal');
118
+ await logger.close();
119
+ process.exit(1);
120
+ });
121
+
122
+ const list = choices
123
+ .map((choice, i) => `[${i + 1}] ${choice}`)
124
+ .join('\n');
125
+
126
+ const promptText = `${question}\n\n${list}\n\nEnter the number of your choice (Ctrl-C to abort): `;
127
+
128
+ let index;
129
+
130
+ while (true) {
131
+ const answer = await rl.question(promptText);
132
+ logger.dump(promptText + '\n' + answer + '\n');
133
+
134
+ index = parseInt(answer.trim(), 10) - 1;
135
+
136
+ if (!Number.isNaN(index) && index >= 0 && index < choices.length) {
137
+ break;
138
+ }
139
+
140
+ logger.warn('\nInvalid choice. Please enter a valid number.\n', '', true);
141
+ }
142
+
143
+ rl.close();
144
+ logger.print();
145
+ return index;
146
+ }
147
+
84
148
  export function replaceUrlRegion(inputUrl, srcRegion, targetRegion) {
85
149
  if (!inputUrl) return '';
86
150
 
@@ -10,9 +10,12 @@
10
10
  import { execSync } from 'child_process';
11
11
  import { logger, LOG_STAGES } from './logger.js'
12
12
  import { RESERVED_GRIT_PROJECT_NAMES, RESERVED_GRIT_GROUP_NAMES, RESERVED_GRIT_SUBGROUP_NAME, TERRAFORM_REQUIRED_VERSION, SECRET_KEYS_MAP } from '../../config.js';
13
- import { getToolchainsByName, getToolchainTools, getPipelineData, getAppConfigHealthcheck, getSecretsHealthcheck, getGitOAuth, getGritUserProject, getGritGroup, getGritGroupProject } from './requests.js';
13
+ import { getToolchainsByName, getToolchainTools, getPipelineData, getAppConfigHealthcheck, getSecretsHealthcheck, getGitOAuth, getGritUserProject, getGritGroupProject } from './requests.js';
14
14
  import { promptUserConfirmation, promptUserInput, isSecretReference } from './utils.js';
15
15
 
16
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
17
+ const DEV_MODE = CLOUD_PLATFORM !== 'cloud.ibm.com';
18
+ const GIT_BASE_URL = DEV_MODE ? process.env['IBMCLOUD_GIT_URL'] : '';
16
19
 
17
20
  function validatePrereqsVersions() {
18
21
  const compareVersions = (verInstalled, verRequired) => {
@@ -151,7 +154,7 @@ async function validateTools(token, tcId, region, skipPrompt) {
151
154
  for (const tool of allTools.tools) {
152
155
  const toolName = (tool.name || tool.parameters?.name || tool.parameters?.label || '').replace(/\s+/g, '+');
153
156
  logger.updateSpinnerMsg(`Validating tool \'${toolName}\'`);
154
- const toolUrl = `https://cloud.ibm.com/devops/toolchains/${tool.toolchain_id}/configure/${tool.id}?env_id=ibm:yp:${region}`;
157
+ const toolUrl = `https://${CLOUD_PLATFORM}/devops/toolchains/${tool.toolchain_id}/configure/${tool.id}?env_id=ibm:yp:${region}`;
155
158
 
156
159
  if (tool.state !== 'configured') { // Check for tools in misconfigured/unconfigured/configuring state
157
160
  nonConfiguredTools.push({
@@ -278,7 +281,7 @@ async function validateTools(token, tcId, region, skipPrompt) {
278
281
  }
279
282
 
280
283
  if (toolsWithHashedParams.length > 0) {
281
- logger.warn('Warning! The following tools contain secrets that cannot be migrated, please use the \'check-secrets\' command to export the secrets: \n', LOG_STAGES.setup, true);
284
+ logger.warn('Warning! The following tools contain secrets that cannot be migrated, please use the \'export-secrets\' command to export the secrets: \n', LOG_STAGES.setup, true);
282
285
  logger.table(toolsWithHashedParams);
283
286
  }
284
287
 
@@ -394,7 +397,7 @@ async function validateOAuth(token, tools, targetRegion, skipPrompt) {
394
397
  logger.print(`${o.type}: \x1b[36m${o.link}\x1b[0m\n`);
395
398
  });
396
399
 
397
- if (hasFailedLink) logger.print(`Please manually verify failed authorization(s): https://cloud.ibm.com/devops/git?env_id=ibm:yp:${targetRegion}\n`);
400
+ if (hasFailedLink) logger.print(`Please manually verify failed authorization(s): https://${CLOUD_PLATFORM}/devops/git?env_id=ibm:yp:${targetRegion}\n`);
398
401
 
399
402
  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.');
400
403
  }
@@ -405,8 +408,9 @@ async function validateGritUrl(token, region, url, validateFull) {
405
408
  let trimmed;
406
409
 
407
410
  if (validateFull) {
408
- if (!url.startsWith(`https://${region}.git.cloud.ibm.com/`) || !url.endsWith('.git')) throw Error('Provided full GRIT url is not valid');
409
- trimmed = url.slice(`https://${region}.git.cloud.ibm.com/`.length, url.length - '.git'.length);
411
+ const baseUrl = (GIT_BASE_URL || `https://${region}.git.cloud.ibm.com`) + '/';
412
+ if (!url.startsWith(baseUrl) || !url.endsWith('.git')) throw Error('Provided full GRIT url is not valid');
413
+ trimmed = url.slice(baseUrl.length, url.length - '.git'.length);
410
414
  } else {
411
415
  trimmed = url.trim();
412
416
  }
@@ -16,9 +16,13 @@ if (!API_KEY) throw Error(`Missing 'IBMCLOUD_API_KEY'`);
16
16
  const TC_ID = process.env['TARGET_TOOLCHAIN_ID'];
17
17
  if (!TC_ID) throw Error(`Missing 'TARGET_TOOLCHAIN_ID'`);
18
18
 
19
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM'] || 'cloud.ibm.com';
20
+ if (!CLOUD_PLATFORM) throw Error(`Missing 'IBMCLOUD_PLATFORM'`);
21
+
22
+ const IAM_BASE_URL = process.env['IAM_BASE_URL'] || 'https://iam.cloud.ibm.com';
23
+ if (!IAM_BASE_URL) throw Error(`Missing 'IAM_BASE_URL'`);
24
+
19
25
  const INPUT_PATH = 'create-s2s.json';
20
- const CLOUD_PLATFORM = 'https://cloud.ibm.com';
21
- const IAM_BASE_URL = 'https://iam.cloud.ibm.com';
22
26
 
23
27
  async function getBearer() {
24
28
  const url = `${IAM_BASE_URL}/identity/token`;
@@ -69,7 +73,7 @@ async function getBearer() {
69
73
  */
70
74
 
71
75
  async function createS2sAuthPolicy(bearer, item) {
72
- const url = `${CLOUD_PLATFORM}/devops/setup/api/v2/s2s_authorization?${new URLSearchParams({
76
+ const url = `https://${CLOUD_PLATFORM}/devops/setup/api/v2/s2s_authorization?${new URLSearchParams({
73
77
  toolchainId: TC_ID,
74
78
  serviceId: item['serviceId'],
75
79
  env_id: item['env_id']
package/index.js CHANGED
@@ -10,13 +10,20 @@
10
10
 
11
11
  import { program } from 'commander';
12
12
  import * as commands from './cmd/index.js'
13
+ import { DOCS_URL } from './config.js';
14
+ import { logger } from './cmd/utils/logger.js';
15
+
13
16
  import packageJson from './package.json' with { type: "json" };
14
17
 
18
+ process.on('exit', (code) => {
19
+ if (code !== 0) logger.print(`Need help? Visit ${DOCS_URL} for more troubleshooting information.`);
20
+ });
21
+
15
22
  program
16
- .name(packageJson.name)
17
- .description('Tools and utilities for the IBM Cloud Continuous Delivery service and resources.')
18
- .version(packageJson.version)
19
- .showHelpAfterError();
23
+ .name(packageJson.name)
24
+ .description('Tools and utilities for the IBM Cloud Continuous Delivery service and resources.')
25
+ .version(packageJson.version)
26
+ .showHelpAfterError();
20
27
 
21
28
  for (let i in commands) {
22
29
  program.addCommand(commands[i]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibm-cloud/cd-tools",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",
package/test/README.md CHANGED
@@ -36,6 +36,6 @@ You can customize the behavior of the tests by defining configuration properties
36
36
  | `TEST_TEMP_DIR` | `string` | `test/.tmp` | The directory to store temporary files generated by test cases |
37
37
  | `TEST_LOG_DIR` | `string` | `test/.test-logs` | The directory to store test run log files |
38
38
  | `IBMCLOUD_API_KEY` | `string` | `null` | The IBM Cloud API Key used to run the tests |
39
- | `LOG_DUMP` | `boolean` | `false` | When set to `true`, individual test case's process's log file generation is enabled |
39
+ | `LOG_DUMP` | `boolean` | `false` | When set to `true`, individual test case's process's log file generation is enabled, and logs are written to `TEST_TEMP_DIR` |
40
40
  | `DISABLE_SPINNER` | `boolean` | `true` | When set to `true`, visual spinner is disabled across all test cases' processes |
41
- | `VERBOSE_MODE` | `boolean` | `false` | When set to `true`, each test case's log output increases |
41
+ | `VERBOSE_MODE` | `boolean` | `false` | When set to `true`, each test case's log output increases |
@@ -3,7 +3,7 @@
3
3
  "TEST_TEMP_DIR": "test/.tmp",
4
4
  "TEST_LOG_DIR": "test/.logs",
5
5
  "IBMCLOUD_API_KEY": "<YOUR IBMCLOUD API KEY>",
6
- "LOG_DUMP": true,
6
+ "LOG_DUMP": false,
7
7
  "DISABLE_SPINNER": true,
8
8
  "VERBOSE_MODE": false
9
9
  }
@@ -11,8 +11,6 @@ import path from 'node:path';
11
11
  import nconf from 'nconf';
12
12
  import fs from 'node:fs';
13
13
 
14
- import * as chai from 'chai';
15
- chai.config.truncateThreshold = 0;
16
14
  import { expect, assert } from 'chai';
17
15
 
18
16
  import { assertPtyOutput, assertExecError, areFilesInDir, deleteCreatedToolchains, parseTcIdAndRegion } from '../utils/testUtils.js';
@@ -11,9 +11,7 @@ import path from 'node:path';
11
11
  import nconf from 'nconf';
12
12
  import fs from 'node:fs';
13
13
 
14
- import * as chai from 'chai';
15
14
  import { expect } from 'chai';
16
- chai.config.truncateThreshold = 0;
17
15
 
18
16
  import mocks from '../data/mocks.js';
19
17
  import { assertExecError, assertPtyOutput } from '../utils/testUtils.js';
@@ -81,12 +79,12 @@ describe('copy-toolchain: Test user input handling', function () {
81
79
  {
82
80
  name: 'Invalid Resource Group name is provided',
83
81
  cmd: [CLI_PATH, COMMAND, '-c', validCrn, '-r', TARGET_REGIONS[0], '-g', mocks.invalidRgName],
84
- expected: /The resource group with provided ID or name was not found or is not accessible/,
82
+ expected: /No matching resource groups were found for the provided id\(s\) or name\(s\)/,
85
83
  },
86
84
  {
87
85
  name: 'Invalid Resource Group ID is provided',
88
86
  cmd: [CLI_PATH, COMMAND, '-c', validCrn, '-r', TARGET_REGIONS[0], '-g', mocks.invalidRgId],
89
- expected: /The resource group with provided ID or name was not found or is not accessible/,
87
+ expected: /No matching resource groups were found for the provided id\(s\) or name\(s\)/,
90
88
  },
91
89
  {
92
90
  name: 'Non-existent GRIT mapping file provided',
@@ -10,9 +10,6 @@
10
10
  import path from 'node:path';
11
11
  import nconf from 'nconf';
12
12
 
13
- import * as chai from 'chai';
14
- chai.config.truncateThreshold = 0;
15
-
16
13
  import { assertTfResourcesInDir, assertPtyOutput } from '../utils/testUtils.js';
17
14
  import { TEST_TOOLCHAINS } from '../data/test-toolchains.js';
18
15
 
@@ -10,8 +10,6 @@
10
10
  import path from 'node:path';
11
11
  import nconf from 'nconf';
12
12
 
13
- import * as chai from 'chai';
14
- chai.config.truncateThreshold = 0;
15
13
  import { expect } from 'chai';
16
14
 
17
15
  import { assertPtyOutput, deleteCreatedToolchains } from '../utils/testUtils.js';
@@ -87,7 +85,7 @@ describe('copy-toolchain: Test tool validation', function () {
87
85
  timeout: 30000
88
86
  },
89
87
  assertionFunc: (output) => {
90
- expect(output).to.match(/Warning! The following GRIT integration\(s\) are using auth_type "pat", please switch to auth_type "oauth" before proceeding/);
88
+ expect(output).to.match(/Warning! The following GRIT integration\(s\) with auth_type "pat" are unsupported during migration and will automatically be converted to auth_type "oauth"/);
91
89
  expect(output).to.match(/hostedgit/);
92
90
  expect(output).to.match(/Warning! The following tools contain secrets that cannot be migrated/);
93
91
  expect(output).to.match(/githubconsolidated[\s\S]*?api_token/);
@@ -1,111 +0,0 @@
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
- 'use strict';
10
-
11
- import { exit } from 'node:process';
12
- import { Command } from 'commander';
13
- import { parseEnvVar, decomposeCrn, isSecretReference } from './utils/utils.js';
14
- import { logger, LOG_STAGES } from './utils/logger.js';
15
- import { getBearerToken, getToolchainTools, getPipelineData } from './utils/requests.js';
16
- import { SECRET_KEYS_MAP } from '../config.js';
17
-
18
- const command = new Command('check-secrets')
19
- .description('Checks if you have any stored secrets in your toolchain or pipelines')
20
- .requiredOption('-c, --toolchain-crn <crn>', 'The CRN of the source toolchain to check')
21
- .option('-a --apikey <api key>', 'IBM Cloud IAM API key with permissions to read the toolchain.')
22
- .showHelpAfterError()
23
- .hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
24
- .action(main);
25
-
26
- async function main(options) {
27
- const toolchainCrn = options.toolchainCrn;
28
- const apiKey = options.apikey || parseEnvVar('IBMCLOUD_API_KEY');
29
-
30
- if (!apiKey) {
31
- logger.error('Missing IBM Cloud IAM API key', LOG_STAGES.setup);
32
- exit(1);
33
- };
34
-
35
- logger.print(`Checking secrets for toolchain ${toolchainCrn}...`);
36
-
37
- const decomposedCrn = decomposeCrn(toolchainCrn);
38
-
39
- const token = await getBearerToken(apiKey);
40
- const toolchainId = decomposedCrn.serviceInstance;
41
- const region = decomposedCrn.location;
42
-
43
- const getToolsRes = await getToolchainTools(token, toolchainId, region);
44
-
45
- const toolResults = [];
46
- const pipelineResults = [];
47
-
48
- if (getToolsRes?.tools?.length > 0) {
49
- for (let i = 0; i < getToolsRes.tools.length; i++) {
50
- const tool = getToolsRes.tools[i];
51
-
52
- // Skip iff it's GitHub/GitLab/GRIT integration with OAuth
53
- if (['githubconsolidated', 'github_integrated', 'gitlab', 'hostedgit'].includes(tool.tool_type_id) && (tool.parameters?.auth_type === '' || tool.parameters?.auth_type === 'oauth'))
54
- continue;
55
-
56
- // Check tool integrations for any plain text secret values
57
- if (SECRET_KEYS_MAP[tool.tool_type_id]) {
58
- SECRET_KEYS_MAP[tool.tool_type_id].forEach((entry) => {
59
- const updateableSecretParam = entry.key;
60
- if (tool.parameters[updateableSecretParam] && !isSecretReference(tool.parameters[updateableSecretParam]) && tool.parameters[updateableSecretParam].length > 0) {
61
- toolResults.push({
62
- 'Tool ID': tool.id,
63
- 'Tool Type': tool.tool_type_id,
64
- 'Property Name': updateableSecretParam
65
- });
66
- };
67
- });
68
- };
69
-
70
- // For tekton pipelines, check for any plain text secret properties
71
- if (tool.tool_type_id === 'pipeline' && tool.parameters?.type === 'tekton') {
72
- const pipelineData = await getPipelineData(token, tool.id, region);
73
-
74
- pipelineData?.properties.forEach((prop) => {
75
- if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
76
- pipelineResults.push({
77
- 'Pipeline ID': pipelineData.id,
78
- 'Trigger Name': '-',
79
- 'Property Name': prop.name
80
- });
81
- };
82
- });
83
-
84
- pipelineData?.triggers.forEach((trigger) => {
85
- trigger.properties?.forEach((prop) => {
86
- if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
87
- pipelineResults.push({
88
- 'Pipeline ID': pipelineData.id,
89
- 'Trigger Name': trigger.name,
90
- 'Property Name': prop.name
91
- });
92
- };
93
- });
94
- });
95
- }
96
- };
97
- };
98
-
99
- if (toolResults.length > 0) {
100
- logger.print();
101
- logger.print('The following plain text properties were found in tool integrations bound to the toolchain:');
102
- logger.table(toolResults);
103
- };
104
- if (pipelineResults.length > 0) {
105
- logger.print();
106
- logger.print('The following plain text properties were found in Tekton pipeline(s) bound to the toolchain:');
107
- logger.table(pipelineResults);
108
- };
109
- };
110
-
111
- export default command;