@ibm-cloud/cd-tools 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cmd/direct-transfer.js +160 -28
- package/config.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,9 +132,9 @@ Copies a toolchain, including tool integrations and Tekton pipelines, to another
|
|
|
132
132
|
|
|
133
133
|
Examples:
|
|
134
134
|
export IBMCLOUD_API_KEY='...'
|
|
135
|
-
npx @ibm-cloud/cd-
|
|
135
|
+
npx @ibm-cloud/cd-tools copy-toolchain -c ${TOOLCHAIN_CRN} -r us-south
|
|
136
136
|
Copy a toolchain to the Dallas region with the same name, in the same resource group.
|
|
137
|
-
npx @ibm-cloud/cd-
|
|
137
|
+
npx @ibm-cloud/cd-tools copy-toolchain -c ${TOOLCHAIN_CRN} -r eu-de -n new-toolchain-name -g new-resource-group --apikey ${APIKEY}
|
|
138
138
|
Copy a toolchain to the Frankfurt region with the specified name and target resource group, using the given API key
|
|
139
139
|
|
|
140
140
|
Environment Variables:
|
package/cmd/direct-transfer.js
CHANGED
|
@@ -7,15 +7,19 @@
|
|
|
7
7
|
* Contract with IBM Corp.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { Command
|
|
10
|
+
import { Command } from 'commander';
|
|
11
11
|
import axios from 'axios';
|
|
12
12
|
import readline from 'readline/promises';
|
|
13
|
+
import { writeFile } from 'fs/promises';
|
|
13
14
|
import { TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
|
|
14
15
|
|
|
16
|
+
const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
|
|
17
|
+
|
|
15
18
|
class GitLabClient {
|
|
16
19
|
constructor(baseURL, token) {
|
|
17
20
|
this.client = axios.create({
|
|
18
21
|
baseURL: baseURL.endsWith('/') ? `${baseURL}api/v4` : `${baseURL}/api/v4`,
|
|
22
|
+
timeout: HTTP_TIMEOUT_MS,
|
|
19
23
|
headers: {
|
|
20
24
|
'Authorization': `Bearer ${token}`,
|
|
21
25
|
'Content-Type': 'application/json'
|
|
@@ -23,24 +27,110 @@ class GitLabClient {
|
|
|
23
27
|
});
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
// List all projects in a group + all its subgroups using BFS.
|
|
31
|
+
async getGroupProjects(groupId, { maxProjects = 1000, maxRequests = 2000 } = {}) {
|
|
32
|
+
let requestCount = 0;
|
|
27
33
|
const projects = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
const toVisit = [groupId];
|
|
35
|
+
const visited = new Set();
|
|
36
|
+
|
|
37
|
+
console.log(
|
|
38
|
+
`[DEBUG] Starting BFS project listing from group ${groupId} (maxProjects=${maxProjects}, maxRequests=${maxRequests})`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
while (toVisit.length > 0) {
|
|
42
|
+
const currentGroupId = toVisit.shift();
|
|
43
|
+
if (visited.has(currentGroupId)) continue;
|
|
44
|
+
visited.add(currentGroupId);
|
|
45
|
+
|
|
46
|
+
console.log(`[DEBUG] Visiting group ${currentGroupId}. Remaining groups in queue: ${toVisit.length}`);
|
|
47
|
+
|
|
48
|
+
// List projects for THIS group (no include_subgroups!)
|
|
49
|
+
let projPage = 1;
|
|
50
|
+
let hasMoreProjects = true;
|
|
51
|
+
|
|
52
|
+
while (hasMoreProjects) {
|
|
53
|
+
if (requestCount >= maxRequests || projects.length >= maxProjects) {
|
|
54
|
+
console.warn(`[WARN] Stopping project traversal: requestCount=${requestCount}, projects=${projects.length}`);
|
|
55
|
+
return projects;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const projRes = await this.getWithRetry(
|
|
59
|
+
`/groups/${currentGroupId}/projects`,
|
|
60
|
+
{ page: projPage, per_page: 100 }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
requestCount++;
|
|
64
|
+
const pageProjects = projRes.data || [];
|
|
65
|
+
if (pageProjects.length > 0) {
|
|
66
|
+
projects.push(...pageProjects);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
hasMoreProjects = pageProjects.length === 100;
|
|
70
|
+
projPage++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// List DIRECT subgroups and enqueue them
|
|
74
|
+
let subgroupPage = 1;
|
|
75
|
+
let hasMoreSubgroups = true;
|
|
76
|
+
|
|
77
|
+
while (hasMoreSubgroups) {
|
|
78
|
+
if (requestCount >= maxRequests) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[WARN] Stopping subgroup traversal: requestCount=${requestCount}`
|
|
81
|
+
);
|
|
82
|
+
return projects;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const subgroupRes = await this.getWithRetry(
|
|
86
|
+
`/groups/${currentGroupId}/subgroups`,
|
|
87
|
+
{ page: subgroupPage, per_page: 100 }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
requestCount++;
|
|
91
|
+
const subgroups = subgroupRes.data || [];
|
|
92
|
+
|
|
93
|
+
if (subgroups.length > 0) {
|
|
94
|
+
for (const sg of subgroups) {
|
|
95
|
+
if (!visited.has(sg.id)) {
|
|
96
|
+
toVisit.push(sg.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
hasMoreSubgroups = subgroups.length === 100;
|
|
102
|
+
subgroupPage++;
|
|
103
|
+
}
|
|
39
104
|
}
|
|
40
|
-
|
|
105
|
+
|
|
106
|
+
console.log(`[DEBUG] Finished BFS project listing. Total projects=${projects.length}, total requests=${requestCount}`);
|
|
41
107
|
return projects;
|
|
42
108
|
}
|
|
43
|
-
|
|
109
|
+
|
|
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
|
+
|
|
44
134
|
async getGroup(groupId) {
|
|
45
135
|
const response = await this.client.get(`/groups/${groupId}`);
|
|
46
136
|
return response.data;
|
|
@@ -126,7 +216,7 @@ async function promptUser(name) {
|
|
|
126
216
|
|
|
127
217
|
const answer = await rl.question(`Your new group name is ${name}. Are you sure? (Yes/No)`);
|
|
128
218
|
|
|
129
|
-
rl.close();
|
|
219
|
+
rl.close();
|
|
130
220
|
|
|
131
221
|
if (answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') {
|
|
132
222
|
console.log("Proceeding...");
|
|
@@ -144,6 +234,39 @@ function validateAndConvertRegion(region) {
|
|
|
144
234
|
return `https://${region}.git.cloud.ibm.com/`;
|
|
145
235
|
}
|
|
146
236
|
|
|
237
|
+
// Build a mapping of: old http_url_to_repo -> new http_url_to_repo and old web_url -> new web_url
|
|
238
|
+
async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
|
|
239
|
+
const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
|
|
240
|
+
const urlMapping = {};
|
|
241
|
+
|
|
242
|
+
const groupPrefix = `${sourceGroup.full_path}/`;
|
|
243
|
+
|
|
244
|
+
for (const project of sourceProjects) {
|
|
245
|
+
const oldRepoUrl = project.http_url_to_repo; // ends with .git
|
|
246
|
+
|
|
247
|
+
// path_with_namespace is like "group/subgroup/project-1"
|
|
248
|
+
let relativePath;
|
|
249
|
+
if (project.path_with_namespace.startsWith(groupPrefix)) {
|
|
250
|
+
relativePath = project.path_with_namespace.slice(groupPrefix.length);
|
|
251
|
+
} else {
|
|
252
|
+
// Fallback if for some reason full_path is not a prefix
|
|
253
|
+
relativePath = project.path_with_namespace;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const newRepoUrl = `${destBase}/${destinationGroupPath}/${relativePath}.git`;
|
|
257
|
+
urlMapping[oldRepoUrl] = newRepoUrl;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const mappingFile = 'grit-url-map.json';
|
|
261
|
+
|
|
262
|
+
await writeFile(mappingFile, JSON.stringify(urlMapping, null, 2), {
|
|
263
|
+
encoding: 'utf8',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log(`\nURL mapping JSON generated at: ${mappingFile}`);
|
|
267
|
+
console.log(`Total mapped projects: ${sourceProjects.length}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
147
270
|
async function directTransfer(options) {
|
|
148
271
|
const sourceUrl = validateAndConvertRegion(options.sourceRegion);
|
|
149
272
|
const destUrl = validateAndConvertRegion(options.destRegion);
|
|
@@ -168,6 +291,15 @@ async function directTransfer(options) {
|
|
|
168
291
|
await promptUser(options.newName);
|
|
169
292
|
}
|
|
170
293
|
|
|
294
|
+
// Generate URL mapping JSON before starting the migration
|
|
295
|
+
await generateUrlMappingFile({
|
|
296
|
+
sourceUrl,
|
|
297
|
+
destUrl,
|
|
298
|
+
sourceGroup,
|
|
299
|
+
destinationGroupPath,
|
|
300
|
+
sourceProjects,
|
|
301
|
+
});
|
|
302
|
+
|
|
171
303
|
let bulkImport = null;
|
|
172
304
|
|
|
173
305
|
const requestPayload = {
|
|
@@ -181,10 +313,10 @@ async function directTransfer(options) {
|
|
|
181
313
|
destination_slug: destinationGroupPath,
|
|
182
314
|
destination_namespace: ""
|
|
183
315
|
}]
|
|
184
|
-
}
|
|
316
|
+
};
|
|
185
317
|
|
|
186
318
|
let importRes = null;
|
|
187
|
-
|
|
319
|
+
|
|
188
320
|
try {
|
|
189
321
|
importRes = await destination.bulkImport(requestPayload);
|
|
190
322
|
if (importRes.success) {
|
|
@@ -192,9 +324,9 @@ async function directTransfer(options) {
|
|
|
192
324
|
console.log(`Bulk import request succeeded!`);
|
|
193
325
|
console.log(`Bulk import initiated successfully (ID: ${importRes.data?.id})`);
|
|
194
326
|
} else if (importRes.conflict) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
327
|
+
console.log(`Conflict detected: ${importRes.error}`);
|
|
328
|
+
console.log(`Please specify a new group name using -n, --new-name <n> when trying again`);
|
|
329
|
+
process.exit(0);
|
|
198
330
|
}
|
|
199
331
|
} catch (error) {
|
|
200
332
|
console.log(`Bulk import request failed - ${error.message}`);
|
|
@@ -204,11 +336,11 @@ async function directTransfer(options) {
|
|
|
204
336
|
console.log('\nPolling bulk import status (checking every 5 minute)...');
|
|
205
337
|
let importStatus = 'created';
|
|
206
338
|
let attempts = 0;
|
|
207
|
-
|
|
339
|
+
|
|
208
340
|
while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < 60) {
|
|
209
341
|
if (attempts > 0) {
|
|
210
342
|
console.log(`Waiting 5 minute before next status check...`);
|
|
211
|
-
await new Promise(resolve => setTimeout(resolve, 5*60000));
|
|
343
|
+
await new Promise(resolve => setTimeout(resolve, 5 * 60000));
|
|
212
344
|
}
|
|
213
345
|
try {
|
|
214
346
|
const importDetails = await destination.getBulkImport(bulkImport.id);
|
|
@@ -230,7 +362,7 @@ async function directTransfer(options) {
|
|
|
230
362
|
}
|
|
231
363
|
attempts++;
|
|
232
364
|
}
|
|
233
|
-
|
|
365
|
+
|
|
234
366
|
if (attempts >= 60) {
|
|
235
367
|
console.error(`Bulk import either timed out or is still running in the background`);
|
|
236
368
|
process.exit(0);
|
|
@@ -239,20 +371,20 @@ async function directTransfer(options) {
|
|
|
239
371
|
const entities = await destination.getBulkImportEntities(bulkImport.id);
|
|
240
372
|
const finishedEntities = entities.filter(e => e.status === 'finished');
|
|
241
373
|
const failedEntities = entities.filter(e => e.status === 'failed');
|
|
242
|
-
|
|
374
|
+
|
|
243
375
|
if (importStatus === 'finished' && finishedEntities.length > 0) {
|
|
244
376
|
console.log(`\nGroup migration completed successfully!`);
|
|
245
377
|
console.log(`Migration Results:`);
|
|
246
378
|
console.log(`Successfully migrated: ${finishedEntities.length} entities`);
|
|
247
379
|
console.log(`Failed: ${failedEntities.length} entities`);
|
|
248
|
-
|
|
380
|
+
|
|
249
381
|
if (failedEntities.length > 0) {
|
|
250
382
|
console.log(`\nFailed entities:\n`);
|
|
251
383
|
failedEntities.forEach(e => {
|
|
252
384
|
console.log(`${e.source_type}: ${e.source_full_path} (${e.status})`);
|
|
253
385
|
});
|
|
254
386
|
}
|
|
255
|
-
|
|
387
|
+
|
|
256
388
|
return 0;
|
|
257
389
|
} else {
|
|
258
390
|
console.error('\nBulk import failed!');
|
|
@@ -282,7 +414,7 @@ const command = new Command('copy-project-group')
|
|
|
282
414
|
.showHelpAfterError()
|
|
283
415
|
.hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
|
|
284
416
|
.action(async (options) => {
|
|
285
|
-
await directTransfer(options);
|
|
417
|
+
await directTransfer(options);
|
|
286
418
|
});
|
|
287
419
|
|
|
288
420
|
export default command;
|
package/config.js
CHANGED
|
@@ -11,9 +11,9 @@ const COPY_TOOLCHAIN_DESC = `Copies a toolchain, including tool integrations and
|
|
|
11
11
|
|
|
12
12
|
Examples:
|
|
13
13
|
export IBMCLOUD_API_KEY='...'
|
|
14
|
-
npx @ibm-cloud/cd-
|
|
14
|
+
npx @ibm-cloud/cd-tools copy-toolchain -c \${TOOLCHAIN_CRN} -r us-south
|
|
15
15
|
Copy a toolchain to the Dallas region with the same name, in the same resource group.
|
|
16
|
-
npx @ibm-cloud/cd-
|
|
16
|
+
npx @ibm-cloud/cd-tools copy-toolchain -c \${TOOLCHAIN_CRN} -r eu-de -n new-toolchain-name -g new-resource-group --apikey \${APIKEY}
|
|
17
17
|
Copy a toolchain to the Frankfurt region with the specified name and target resource group, using the given API key
|
|
18
18
|
|
|
19
19
|
Environment Variables:
|