@ibm-cloud/cd-tools 1.11.0 → 1.11.2
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 +13 -8
- package/cmd/direct-transfer.js +87 -33
- package/config.js +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,15 +74,20 @@ The `copy-project-group` command copies a [group](https://docs.gitlab.com/user/g
|
|
|
74
74
|
$ npx @ibm-cloud/cd-tools copy-project-group -h
|
|
75
75
|
Usage: @ibm-cloud/cd-tools copy-project-group [options]
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
Copies all Git Repos and Issue Tracking projects in a group to another region.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
npx @ibm-cloud/cd-tools copy-project-group -g "1796019" -s ca-tor -d us-south --st ${PAT_CA_TOR} --dt ${PAT_US_SOUTH}
|
|
81
|
+
Copy all the Git Repos and Issue Tracking projects in the group "mygroup" from the Toronto region to the Dallas, with the same group name.
|
|
78
82
|
|
|
79
83
|
Options:
|
|
80
|
-
-s, --source-region <region>
|
|
81
|
-
-d, --dest-region <region>
|
|
82
|
-
--st, --source-token <token>
|
|
83
|
-
--dt, --dest-token <token>
|
|
84
|
-
-g, --group-id <id>
|
|
85
|
-
|
|
84
|
+
-s, --source-region <region> The source region from which to copy the project group (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")
|
|
85
|
+
-d, --dest-region <region> The destination region to copy the projects to (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")
|
|
86
|
+
--st, --source-token <token> A Git Repos and Issue Tracking personal access token from the source region. The api scope is required on the token.
|
|
87
|
+
--dt, --dest-token <token> A Git Repos and Issue Tracking personal access token from the target region. The api scope is required on the token.
|
|
88
|
+
-g, --group-id <id> The id of the group to copy from the source region (e.g. "1796019"), or the group name (e.g. "mygroup") for top-level groups. For sub-groups, a path
|
|
89
|
+
is also allowed, e.g. "mygroup/subgroup"
|
|
90
|
+
-n, --new-group-slug <slug> (Optional) Destination group URL slug (single path segment, e.g. "mygroup-copy"). Must be unique. Group display name remains the same as source.
|
|
86
91
|
-h, --help display help for command
|
|
87
92
|
```
|
|
88
93
|
|
|
@@ -153,7 +158,7 @@ Advanced options:
|
|
|
153
158
|
-d, --terraform-dir <path> (Optional) The target local directory to store the generated Terraform (.tf) files
|
|
154
159
|
-D, --dry-run (Optional) Skip running terraform apply; only generate the Terraform (.tf) files
|
|
155
160
|
-f, --force (Optional) Force the copy toolchain command to run without user confirmation
|
|
156
|
-
-S, --skip-s2s (Optional) Skip
|
|
161
|
+
-S, --skip-s2s (Optional) Skip creating toolchain-generated service-to-service authorizations
|
|
157
162
|
-T, --skip-disable-triggers (Optional) Skip disabling Tekton pipeline Git or timed triggers. Note: This may result in duplicate pipeline runs
|
|
158
163
|
-C, --compact (Optional) Generate all resources in a single resources.tf file
|
|
159
164
|
-v, --verbose (Optional) Increase log output
|
package/cmd/direct-transfer.js
CHANGED
|
@@ -11,20 +11,24 @@ import { Command } from 'commander';
|
|
|
11
11
|
import axios from 'axios';
|
|
12
12
|
import readline from 'readline/promises';
|
|
13
13
|
import { writeFile } from 'fs/promises';
|
|
14
|
-
import { SOURCE_REGIONS } from '../config.js';
|
|
14
|
+
import { COPY_PROJECT_GROUP_DESC, SOURCE_REGIONS } from '../config.js';
|
|
15
15
|
import { getWithRetry } from './utils/requests.js';
|
|
16
16
|
|
|
17
17
|
const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
|
|
18
18
|
|
|
19
19
|
class GitLabClient {
|
|
20
20
|
constructor(baseURL, token) {
|
|
21
|
+
const root = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
|
22
|
+
|
|
21
23
|
this.client = axios.create({
|
|
22
|
-
baseURL:
|
|
24
|
+
baseURL: `${root}api/v4`,
|
|
23
25
|
timeout: HTTP_TIMEOUT_MS,
|
|
24
|
-
headers: {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
26
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
this.graph = axios.create({
|
|
29
|
+
baseURL: `${root}api`,
|
|
30
|
+
timeout: HTTP_TIMEOUT_MS,
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
28
32
|
});
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -110,6 +114,50 @@ class GitLabClient {
|
|
|
110
114
|
return projects;
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
async listGroupProjectsGraphQL(groupFullPath, { includeSubgroups = true, pageSize = 200, maxProjects = 5000 } = {}) {
|
|
118
|
+
const out = [];
|
|
119
|
+
let after = null;
|
|
120
|
+
|
|
121
|
+
const query = `
|
|
122
|
+
query($fullPath: ID!, $after: String, $includeSubgroups: Boolean!, $pageSize: Int!) {
|
|
123
|
+
group(fullPath: $fullPath) {
|
|
124
|
+
projects(includeSubgroups: $includeSubgroups, first: $pageSize, after: $after) {
|
|
125
|
+
nodes {
|
|
126
|
+
fullPath
|
|
127
|
+
nameWithNamespace
|
|
128
|
+
httpUrlToRepo
|
|
129
|
+
}
|
|
130
|
+
pageInfo {
|
|
131
|
+
hasNextPage
|
|
132
|
+
endCursor
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
while (out.length < maxProjects) {
|
|
140
|
+
const resp = await this.graph.post("/graphql", {
|
|
141
|
+
query,
|
|
142
|
+
variables: { fullPath: groupFullPath, after, includeSubgroups, pageSize },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (resp.data?.errors?.length) {
|
|
146
|
+
throw new Error(`GraphQL errors: ${JSON.stringify(resp.data.errors)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const projects = resp.data?.data?.group?.projects?.nodes || [];
|
|
150
|
+
const pageInfo = resp.data?.data?.group?.projects?.pageInfo;
|
|
151
|
+
|
|
152
|
+
out.push(...projects);
|
|
153
|
+
|
|
154
|
+
if (!pageInfo?.hasNextPage) break;
|
|
155
|
+
after = pageInfo.endCursor;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
113
161
|
async getGroup(groupId) {
|
|
114
162
|
const response = await this.client.get(`/groups/${groupId}`);
|
|
115
163
|
return response.data;
|
|
@@ -250,23 +298,19 @@ function validateAndConvertRegion(region) {
|
|
|
250
298
|
}
|
|
251
299
|
|
|
252
300
|
// Build a mapping of: old http_url_to_repo -> new http_url_to_repo
|
|
253
|
-
async function generateUrlMappingFile({
|
|
301
|
+
async function generateUrlMappingFile({ destUrl, sourceGroup, destinationGroupPath, sourceProjects }) {
|
|
254
302
|
const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
|
|
255
303
|
const urlMapping = {};
|
|
256
304
|
|
|
257
|
-
const groupPrefix = `${sourceGroup.full_path}/`;
|
|
258
|
-
|
|
259
305
|
for (const project of sourceProjects) {
|
|
260
|
-
const oldRepoUrl = project.http_url_to_repo
|
|
306
|
+
const oldRepoUrl = project.http_url_to_repo || project.httpUrlToRepo;
|
|
261
307
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
relativePath = project.path_with_namespace;
|
|
269
|
-
}
|
|
308
|
+
const fullPath = project.path_with_namespace || project.fullPath || "";
|
|
309
|
+
const groupPrefix = `${sourceGroup.full_path}/`;
|
|
310
|
+
|
|
311
|
+
const relativePath = fullPath.startsWith(groupPrefix)
|
|
312
|
+
? fullPath.slice(groupPrefix.length)
|
|
313
|
+
: fullPath;
|
|
270
314
|
|
|
271
315
|
const newRepoUrl = `${destBase}/${destinationGroupPath}/${relativePath}.git`;
|
|
272
316
|
urlMapping[oldRepoUrl] = newRepoUrl;
|
|
@@ -436,7 +480,7 @@ async function handleBulkImportConflict({ destination, destUrl, sourceGroupFullP
|
|
|
436
480
|
}
|
|
437
481
|
|
|
438
482
|
console.log(`\nConflict detected: ${importResErr}`);
|
|
439
|
-
console.log(`Please specify a new group name using -n, --new-
|
|
483
|
+
console.log(`Please specify a new group name using -n, --new-group-slug <n> when trying again`);
|
|
440
484
|
console.log(`\nGroup already migrated.`);
|
|
441
485
|
if (groupUrl) console.log(`Migrated group: ${groupUrl}`);
|
|
442
486
|
if (historyUrl) console.log(`Group import history: ${historyUrl}`);
|
|
@@ -463,23 +507,32 @@ async function directTransfer(options) {
|
|
|
463
507
|
console.log(`Fetching source group from ID: ${options.groupId}...`);
|
|
464
508
|
const sourceGroup = await source.getGroup(options.groupId);
|
|
465
509
|
|
|
466
|
-
|
|
467
|
-
let destinationGroupPath = options.newName || sourceGroup.path;
|
|
510
|
+
let destinationGroupPath = options.newGroupSlug || sourceGroup.path;
|
|
468
511
|
|
|
469
|
-
|
|
512
|
+
let sourceProjects;
|
|
513
|
+
try {
|
|
514
|
+
sourceProjects = await source.listGroupProjectsGraphQL(sourceGroup.full_path, {
|
|
515
|
+
includeSubgroups: true,
|
|
516
|
+
pageSize: 100,
|
|
517
|
+
maxProjects: 10000,
|
|
518
|
+
});
|
|
519
|
+
} catch (e) {
|
|
520
|
+
console.warn(`[WARN] GraphQL listing failed (${e.message}). Falling back to REST safe listing...`);
|
|
521
|
+
sourceProjects = await source.getGroupProjects(sourceGroup.id);
|
|
522
|
+
}
|
|
523
|
+
|
|
470
524
|
console.log(`Found ${sourceProjects.length} projects in source group`);
|
|
471
525
|
if (sourceProjects.length > 0) {
|
|
472
526
|
console.log('Projects to be migrated:');
|
|
473
|
-
sourceProjects.forEach(p => console.log(
|
|
527
|
+
sourceProjects.forEach(p => console.log(p.name_with_namespace || p.nameWithNamespace || p.fullPath));
|
|
474
528
|
}
|
|
475
529
|
|
|
476
|
-
if (options.
|
|
477
|
-
await promptUser(options.
|
|
530
|
+
if (options.newGroupSlug) {
|
|
531
|
+
await promptUser(options.newGroupSlug);
|
|
478
532
|
}
|
|
479
533
|
|
|
480
534
|
// Generate URL mapping JSON before starting the migration
|
|
481
535
|
await generateUrlMappingFile({
|
|
482
|
-
sourceUrl,
|
|
483
536
|
destUrl,
|
|
484
537
|
sourceGroup,
|
|
485
538
|
destinationGroupPath,
|
|
@@ -624,13 +677,14 @@ async function directTransfer(options) {
|
|
|
624
677
|
}
|
|
625
678
|
|
|
626
679
|
const command = new Command('copy-project-group')
|
|
627
|
-
.
|
|
628
|
-
.
|
|
629
|
-
.requiredOption('-
|
|
630
|
-
.requiredOption('
|
|
631
|
-
.requiredOption('--
|
|
632
|
-
.requiredOption('
|
|
633
|
-
.
|
|
680
|
+
.summary('Copies all Git Repos and Issue Tracking projects in a group to another region.')
|
|
681
|
+
.description(COPY_PROJECT_GROUP_DESC)
|
|
682
|
+
.requiredOption('-s, --source-region <region>', 'The source region from which to copy the project group (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")')
|
|
683
|
+
.requiredOption('-d, --dest-region <region>', 'The destination region to copy the projects to (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")')
|
|
684
|
+
.requiredOption('--st, --source-token <token>', 'A Git Repos and Issue Tracking personal access token from the source region. The api scope is required on the token.')
|
|
685
|
+
.requiredOption('--dt, --dest-token <token>', 'A Git Repos and Issue Tracking personal access token from the target region. The api scope is required on the token.')
|
|
686
|
+
.requiredOption('-g, --group-id <id>', 'The id of the group to copy from the source region (e.g. "1796019"), or the group name (e.g. "mygroup") for top-level groups. For sub-groups, a path is also allowed, e.g. "mygroup/subgroup"')
|
|
687
|
+
.option('-n, --new-group-slug <slug>', '(Optional) Destination group URL slug (single path segment, e.g. "mygroup-copy"). Must be unique. Group display name remains the same as source.')
|
|
634
688
|
.showHelpAfterError()
|
|
635
689
|
.hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
|
|
636
690
|
.action(async (options) => {
|
package/config.js
CHANGED
|
@@ -17,7 +17,13 @@ Examples:
|
|
|
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:
|
|
20
|
-
IBMCLOUD_API_KEY API key used to authenticate. Must have IAM permission to read and create toolchains and service-to-service authorizations in source and target region / resource group
|
|
20
|
+
IBMCLOUD_API_KEY API key used to authenticate. Must have IAM permission to read and create toolchains and service-to-service authorizations in source and target region / resource group`;
|
|
21
|
+
|
|
22
|
+
const COPY_PROJECT_GROUP_DESC = `Copies all Git Repos and Issue Tracking projects in a group to another region.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
npx @ibm-cloud/cd-tools copy-project-group -g "1796019" -s ca-tor -d us-south --st \${PAT_CA_TOR} --dt \${PAT_US_SOUTH}
|
|
26
|
+
Copy all the Git Repos and Issue Tracking projects in the group "mygroup" from the Toronto region to the Dallas, with the same group name.`;
|
|
21
27
|
|
|
22
28
|
const DOCS_URL = 'https://github.com/IBM/continuous-delivery-tools';
|
|
23
29
|
|
|
@@ -222,6 +228,7 @@ const VAULT_REGEX = [
|
|
|
222
228
|
|
|
223
229
|
export {
|
|
224
230
|
COPY_TOOLCHAIN_DESC,
|
|
231
|
+
COPY_PROJECT_GROUP_DESC,
|
|
225
232
|
DOCS_URL,
|
|
226
233
|
SOURCE_REGIONS,
|
|
227
234
|
TARGET_REGIONS,
|