@ibm-cloud/cd-tools 1.7.0 → 1.8.1
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/cmd/direct-transfer.js +156 -34
- package/cmd/utils/requests.js +24 -1
- package/cmd/utils/terraform.js +1 -1
- package/cmd/utils/validate.js +9 -6
- package/package.json +1 -1
package/cmd/direct-transfer.js
CHANGED
|
@@ -12,6 +12,7 @@ import axios from 'axios';
|
|
|
12
12
|
import readline from 'readline/promises';
|
|
13
13
|
import { writeFile } from 'fs/promises';
|
|
14
14
|
import { TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
|
|
15
|
+
import { getWithRetry } from './utils/requests.js';
|
|
15
16
|
|
|
16
17
|
const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
|
|
17
18
|
|
|
@@ -55,7 +56,8 @@ class GitLabClient {
|
|
|
55
56
|
return projects;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
const projRes = await
|
|
59
|
+
const projRes = await getWithRetry(
|
|
60
|
+
this.client,
|
|
59
61
|
`/groups/${currentGroupId}/projects`,
|
|
60
62
|
{ page: projPage, per_page: 100 }
|
|
61
63
|
);
|
|
@@ -82,7 +84,8 @@ class GitLabClient {
|
|
|
82
84
|
return projects;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
const subgroupRes = await
|
|
87
|
+
const subgroupRes = await getWithRetry(
|
|
88
|
+
this.client,
|
|
86
89
|
`/groups/${currentGroupId}/subgroups`,
|
|
87
90
|
{ page: subgroupPage, per_page: 100 }
|
|
88
91
|
);
|
|
@@ -107,30 +110,6 @@ class GitLabClient {
|
|
|
107
110
|
return projects;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
// Helper: GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
|
|
111
|
-
async getWithRetry(path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
|
|
112
|
-
let lastError;
|
|
113
|
-
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
114
|
-
try {
|
|
115
|
-
return await this.client.get(path, { params });
|
|
116
|
-
} catch (error) {
|
|
117
|
-
const status = error.response?.status;
|
|
118
|
-
|
|
119
|
-
if (attempt < retries && status && status >= 500) {
|
|
120
|
-
console.warn(
|
|
121
|
-
`[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
|
|
122
|
-
);
|
|
123
|
-
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
|
|
124
|
-
lastError = error;
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
throw error; // Non-5xx or out of retries: rethrow
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
throw lastError;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
113
|
async getGroup(groupId) {
|
|
135
114
|
const response = await this.client.get(`/groups/${groupId}`);
|
|
136
115
|
return response.data;
|
|
@@ -206,6 +185,28 @@ class GitLabClient {
|
|
|
206
185
|
throw new Error(`Bulk import API call failed: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`);
|
|
207
186
|
}
|
|
208
187
|
}
|
|
188
|
+
|
|
189
|
+
async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
|
|
190
|
+
const all = [];
|
|
191
|
+
let page = 1;
|
|
192
|
+
|
|
193
|
+
while (page <= maxPages) {
|
|
194
|
+
const resp = await getWithRetry(
|
|
195
|
+
this.client,
|
|
196
|
+
`/bulk_imports/${importId}/entities`,
|
|
197
|
+
{ page, per_page: perPage }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
all.push(...(resp.data || []));
|
|
201
|
+
|
|
202
|
+
const nextPage = Number(resp.headers?.['x-next-page'] || 0);
|
|
203
|
+
if (!nextPage) break;
|
|
204
|
+
|
|
205
|
+
page = nextPage;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return all;
|
|
209
|
+
}
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
async function promptUser(name) {
|
|
@@ -234,7 +235,7 @@ function validateAndConvertRegion(region) {
|
|
|
234
235
|
return `https://${region}.git.cloud.ibm.com/`;
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
// Build a mapping of: old http_url_to_repo -> new http_url_to_repo
|
|
238
|
+
// Build a mapping of: old http_url_to_repo -> new http_url_to_repo
|
|
238
239
|
async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
|
|
239
240
|
const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
|
|
240
241
|
const urlMapping = {};
|
|
@@ -267,6 +268,99 @@ async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinat
|
|
|
267
268
|
console.log(`Total mapped projects: ${sourceProjects.length}`);
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
function buildGroupImportHistoryUrl(destUrl) {
|
|
272
|
+
try {
|
|
273
|
+
return new URL('import/bulk_imports/history', destUrl).toString();
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function summarizeBulkImportProgress(entities = []) {
|
|
280
|
+
let entityTotal = 0;
|
|
281
|
+
let entityFinished = 0;
|
|
282
|
+
let entityFailed = 0;
|
|
283
|
+
|
|
284
|
+
let projectTotal = 0;
|
|
285
|
+
let projectFinished = 0;
|
|
286
|
+
let projectFailed = 0;
|
|
287
|
+
|
|
288
|
+
let lastCompleted = null;
|
|
289
|
+
let lastCompletedTs = 0;
|
|
290
|
+
|
|
291
|
+
for (const e of entities) {
|
|
292
|
+
entityTotal++;
|
|
293
|
+
|
|
294
|
+
const status = e.status;
|
|
295
|
+
const isFinished = status === 'finished';
|
|
296
|
+
const isFailed = status === 'failed';
|
|
297
|
+
|
|
298
|
+
if (isFinished) entityFinished++;
|
|
299
|
+
if (isFailed) entityFailed++;
|
|
300
|
+
|
|
301
|
+
const isProjectEntity =
|
|
302
|
+
e.source_type === 'project_entity' ||
|
|
303
|
+
e.entity_type === 'project_entity' ||
|
|
304
|
+
e.entity_type === 'project';
|
|
305
|
+
|
|
306
|
+
if (isProjectEntity) {
|
|
307
|
+
projectTotal++;
|
|
308
|
+
if (isFinished) projectFinished++;
|
|
309
|
+
if (isFailed) projectFailed++;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isFinished) {
|
|
313
|
+
const ts = new Date(e.updated_at || e.created_at || 0).getTime();
|
|
314
|
+
if (ts > lastCompletedTs) {
|
|
315
|
+
lastCompletedTs = ts;
|
|
316
|
+
lastCompleted = e;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const entityDone = entityFinished + entityFailed;
|
|
322
|
+
const entityPct = entityTotal ? Math.floor((entityDone / entityTotal) * 100) : 0;
|
|
323
|
+
|
|
324
|
+
const projectDone = projectFinished + projectFailed;
|
|
325
|
+
const projectPct = projectTotal ? Math.floor((projectDone / projectTotal) * 100) : 0;
|
|
326
|
+
|
|
327
|
+
const lastCompletedLabel = lastCompleted?.source_full_path || '';
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
entityTotal,
|
|
331
|
+
entityDone,
|
|
332
|
+
entityFailed,
|
|
333
|
+
entityPct,
|
|
334
|
+
projectTotal,
|
|
335
|
+
projectDone,
|
|
336
|
+
projectFailed,
|
|
337
|
+
projectPct,
|
|
338
|
+
lastCompletedLabel,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatBulkImportProgressLine(importStatus, summary) {
|
|
343
|
+
if (!summary || summary.entityTotal === 0) {
|
|
344
|
+
return `Import status: ${importStatus} | Progress: initializing...`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const parts = [`Import status: ${importStatus}`];
|
|
348
|
+
|
|
349
|
+
if (summary.projectTotal > 0) {
|
|
350
|
+
parts.push(`Projects: ${summary.projectDone}/${summary.projectTotal} (${summary.projectPct}%)`);
|
|
351
|
+
if (summary.projectFailed > 0) parts.push(`Project failed: ${summary.projectFailed}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
parts.push(`Entities: ${summary.entityDone}/${summary.entityTotal} (${summary.entityPct}%)`);
|
|
355
|
+
if (summary.entityFailed > 0) parts.push(`Failed: ${summary.entityFailed}`);
|
|
356
|
+
|
|
357
|
+
if (summary.lastCompletedLabel) {
|
|
358
|
+
parts.push(`Last completed: ${summary.lastCompletedLabel}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return parts.join(' | ');
|
|
362
|
+
}
|
|
363
|
+
|
|
270
364
|
async function directTransfer(options) {
|
|
271
365
|
const sourceUrl = validateAndConvertRegion(options.sourceRegion);
|
|
272
366
|
const destUrl = validateAndConvertRegion(options.destRegion);
|
|
@@ -333,19 +427,36 @@ async function directTransfer(options) {
|
|
|
333
427
|
process.exit(0);
|
|
334
428
|
}
|
|
335
429
|
|
|
336
|
-
console.log('\nPolling bulk import status (
|
|
430
|
+
console.log('\nPolling bulk import status (adaptive: 1m→2m→3m→4m→5m, max 60 checks)...');
|
|
431
|
+
const MAX_ATTEMPTS = 60;
|
|
432
|
+
const POLLS_PER_STEP = 5;
|
|
433
|
+
const MIN_INTERVAL_MIN = 1;
|
|
434
|
+
const MAX_INTERVAL_MIN = 5;
|
|
435
|
+
|
|
337
436
|
let importStatus = 'created';
|
|
338
437
|
let attempts = 0;
|
|
339
438
|
|
|
340
|
-
while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts <
|
|
439
|
+
while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < MAX_ATTEMPTS) {
|
|
341
440
|
if (attempts > 0) {
|
|
342
|
-
|
|
343
|
-
|
|
441
|
+
const step = Math.floor(attempts / POLLS_PER_STEP);
|
|
442
|
+
const waitMin = Math.min(MIN_INTERVAL_MIN + step, MAX_INTERVAL_MIN);
|
|
443
|
+
|
|
444
|
+
console.log(`Waiting ${waitMin} minute before next status check...`);
|
|
445
|
+
await new Promise(resolve => setTimeout(resolve, waitMin * 60000));
|
|
344
446
|
}
|
|
345
447
|
try {
|
|
346
448
|
const importDetails = await destination.getBulkImport(bulkImport.id);
|
|
347
449
|
importStatus = importDetails.status;
|
|
348
|
-
|
|
450
|
+
let progressLine;
|
|
451
|
+
try {
|
|
452
|
+
const entitiesAll = await destination.getBulkImportEntitiesAll(bulkImport.id);
|
|
453
|
+
const summary = summarizeBulkImportProgress(entitiesAll);
|
|
454
|
+
progressLine = formatBulkImportProgressLine(importStatus, summary);
|
|
455
|
+
} catch {
|
|
456
|
+
progressLine = `Import status: ${importStatus} | Progress: (unable to fetch entity details)`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${progressLine}`);
|
|
349
460
|
|
|
350
461
|
if (importStatus === 'finished') {
|
|
351
462
|
console.log('Bulk import completed successfully!');
|
|
@@ -363,8 +474,19 @@ async function directTransfer(options) {
|
|
|
363
474
|
attempts++;
|
|
364
475
|
}
|
|
365
476
|
|
|
366
|
-
if (attempts >=
|
|
367
|
-
|
|
477
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
478
|
+
const historyUrl = buildGroupImportHistoryUrl(destUrl);
|
|
479
|
+
|
|
480
|
+
console.error('\nThe CLI has stopped polling for the GitLab bulk import.');
|
|
481
|
+
console.error('The migration itself may still be running inside GitLab — the CLI only waits for a limited time.');
|
|
482
|
+
console.error(`Last reported status for bulk import ${bulkImport.id}: ${importStatus}`);
|
|
483
|
+
|
|
484
|
+
if (historyUrl) {
|
|
485
|
+
console.error('\nYou can continue monitoring this migration in the GitLab UI.');
|
|
486
|
+
console.error(`Group import history: ${historyUrl}`);
|
|
487
|
+
} else {
|
|
488
|
+
console.error('\nYou can continue monitoring this migration from the Group import history page in the GitLab UI.');
|
|
489
|
+
}
|
|
368
490
|
process.exit(0);
|
|
369
491
|
}
|
|
370
492
|
|
package/cmd/utils/requests.js
CHANGED
|
@@ -477,6 +477,28 @@ async function migrateToolchainSecrets(bearer, data, region) {
|
|
|
477
477
|
}
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
// GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
|
|
481
|
+
async function getWithRetry(client, path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
|
|
482
|
+
let lastError;
|
|
483
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
484
|
+
try {
|
|
485
|
+
return await client.get(path, { params });
|
|
486
|
+
} catch (error) {
|
|
487
|
+
const status = error.response?.status;
|
|
488
|
+
if (attempt < retries && status && status >= 500) {
|
|
489
|
+
console.warn(
|
|
490
|
+
`[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
|
|
491
|
+
);
|
|
492
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
|
|
493
|
+
lastError = error;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
throw error; // Non-5xx or out of retries: rethrow
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
throw lastError;
|
|
500
|
+
}
|
|
501
|
+
|
|
480
502
|
export {
|
|
481
503
|
getBearerToken,
|
|
482
504
|
getAccountId,
|
|
@@ -495,5 +517,6 @@ export {
|
|
|
495
517
|
deleteToolchain,
|
|
496
518
|
createTool,
|
|
497
519
|
getSmInstances,
|
|
498
|
-
migrateToolchainSecrets
|
|
520
|
+
migrateToolchainSecrets,
|
|
521
|
+
getWithRetry
|
|
499
522
|
}
|
package/cmd/utils/terraform.js
CHANGED
|
@@ -205,7 +205,7 @@ async function setupTerraformFiles({ token, srcRegion, targetRegion, targetTag,
|
|
|
205
205
|
logger.print('Please enter the new URLs for the following GRIT tool(s) (or submit empty input to skip):\n');
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
const newRepoSlug = await promptUserInput(`Old URL: ${thisUrl.slice(0, thisUrl.length - 4)}\nNew URL: ${GIT_BASE_URL || 'https://' + targetRegion + '.git.cloud.ibm.com'}
|
|
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);
|
|
209
209
|
|
|
210
210
|
if (newRepoSlug) {
|
|
211
211
|
newUrl = (GIT_BASE_URL || `https://${targetRegion}.git.cloud.ibm.com`) + `/${newRepoSlug}.git`;
|
package/cmd/utils/validate.js
CHANGED
|
@@ -405,14 +405,17 @@ async function validateOAuth(token, tools, targetRegion, skipPrompt) {
|
|
|
405
405
|
|
|
406
406
|
async function validateGritUrl(token, region, url, validateFull) {
|
|
407
407
|
if (typeof url != 'string') throw Error('Provided GRIT url is not a string');
|
|
408
|
-
let trimmed;
|
|
408
|
+
let trimmed = url.trim();
|
|
409
409
|
|
|
410
410
|
if (validateFull) {
|
|
411
411
|
const baseUrl = (GIT_BASE_URL || `https://${region}.git.cloud.ibm.com`) + '/';
|
|
412
|
-
if (!
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
412
|
+
if (!trimmed.startsWith(baseUrl)) throw Error('Provided full GRIT url is not valid');
|
|
413
|
+
|
|
414
|
+
if (trimmed.endsWith('.git')) {
|
|
415
|
+
trimmed = trimmed.slice(baseUrl.length, trimmed.length - '.git'.length);
|
|
416
|
+
} else {
|
|
417
|
+
trimmed = trimmed.slice(baseUrl.length);
|
|
418
|
+
}
|
|
416
419
|
}
|
|
417
420
|
|
|
418
421
|
// split into two parts, user/group/subgroup and project
|
|
@@ -468,7 +471,7 @@ async function validateGritUrl(token, region, url, validateFull) {
|
|
|
468
471
|
await getGritGroupProject(accessToken, region, urlStart, projectName);
|
|
469
472
|
return trimmed;
|
|
470
473
|
} catch {
|
|
471
|
-
throw Error(
|
|
474
|
+
throw Error(`Provided GRIT url not found: ${url}`);
|
|
472
475
|
}
|
|
473
476
|
}
|
|
474
477
|
|