@ibm-cloud/cd-tools 1.10.0 → 1.11.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/copy-toolchain.js +7 -3
- package/cmd/direct-transfer.js +91 -38
- package/cmd/export-secrets.js +1 -1
- package/cmd/utils/logger.js +1 -6
- package/cmd/utils/requests.js +23 -20
- package/cmd/utils/terraform.js +1 -3
- package/cmd/utils/validate.js +1 -8
- package/package.json +1 -1
- package/test/copy-toolchain/functionalities.test.js +13 -12
package/cmd/copy-toolchain.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Licensed Materials - Property of IBM
|
|
3
|
-
* (c) Copyright IBM Corporation 2025. All Rights Reserved.
|
|
3
|
+
* (c) Copyright IBM Corporation 2025, 2026. All Rights Reserved.
|
|
4
4
|
*
|
|
5
5
|
* Note to U.S. Government Users Restricted Rights:
|
|
6
6
|
* Use, duplication or disclosure restricted by GSA ADP Schedule
|
|
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
|
|
19
19
|
|
|
20
20
|
import { Command, Option } from 'commander';
|
|
21
21
|
|
|
22
|
-
import { parseEnvVar } from './utils/utils.js';
|
|
22
|
+
import { parseEnvVar, promptUserConfirmation } 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
25
|
import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroups, getToolchain } from './utils/requests.js';
|
|
@@ -110,7 +110,11 @@ async function main(options) {
|
|
|
110
110
|
const accountId = await getAccountId(bearer, apiKey);
|
|
111
111
|
|
|
112
112
|
// check for continuous delivery instance in target region
|
|
113
|
-
if (!await getCdInstanceByRegion(bearer, accountId, targetRegion))
|
|
113
|
+
if (!await getCdInstanceByRegion(bearer, accountId, targetRegion)) {
|
|
114
|
+
// give users the option to bypass
|
|
115
|
+
logger.warn(`Warning! Could not find a Continuous Delivery instance in the target region '${targetRegion}' or you do not have permission to view, please create one before proceeding if one does not exist already.`, LOG_STAGES.setup);
|
|
116
|
+
await promptUserConfirmation(`Do you want to proceed anyway?`, 'yes', 'Toolchain migration cancelled.');
|
|
117
|
+
}
|
|
114
118
|
|
|
115
119
|
// check for existing .tf files in output directory
|
|
116
120
|
if (fs.existsSync(outputDir)) {
|
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 {
|
|
14
|
+
import { 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;
|
|
@@ -186,26 +234,26 @@ class GitLabClient {
|
|
|
186
234
|
}
|
|
187
235
|
}
|
|
188
236
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
237
|
+
async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
|
|
238
|
+
const all = [];
|
|
239
|
+
let page = 1;
|
|
192
240
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
241
|
+
while (page <= maxPages) {
|
|
242
|
+
const resp = await getWithRetry(
|
|
243
|
+
this.client,
|
|
244
|
+
`/bulk_imports/${importId}/entities`,
|
|
245
|
+
{ page, per_page: perPage }
|
|
246
|
+
);
|
|
199
247
|
|
|
200
|
-
|
|
248
|
+
all.push(...(resp.data || []));
|
|
201
249
|
|
|
202
|
-
|
|
203
|
-
|
|
250
|
+
const nextPage = Number(resp.headers?.['x-next-page'] || 0);
|
|
251
|
+
if (!nextPage) break;
|
|
204
252
|
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
page = nextPage;
|
|
254
|
+
}
|
|
207
255
|
|
|
208
|
-
|
|
256
|
+
return all;
|
|
209
257
|
}
|
|
210
258
|
|
|
211
259
|
async getGroupByFullPath(fullPath) {
|
|
@@ -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;
|
|
@@ -387,7 +431,7 @@ function isGroupEntity(e) {
|
|
|
387
431
|
return e?.source_type === 'group_entity' || e?.entity_type === 'group_entity' || e?.entity_type === 'group';
|
|
388
432
|
}
|
|
389
433
|
|
|
390
|
-
async function handleBulkImportConflict({destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr}) {
|
|
434
|
+
async function handleBulkImportConflict({ destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr }) {
|
|
391
435
|
const historyUrl = buildGroupImportHistoryUrl(destUrl);
|
|
392
436
|
const groupUrl = buildGroupUrl(destUrl, `/groups/${destinationGroupPath}`);
|
|
393
437
|
const fallback = () => {
|
|
@@ -463,14 +507,24 @@ 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
|
-
let destinationGroupName = options.newName || sourceGroup.name;
|
|
467
510
|
let destinationGroupPath = options.newName || 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
530
|
if (options.newName) {
|
|
@@ -479,7 +533,6 @@ async function directTransfer(options) {
|
|
|
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,
|
package/cmd/export-secrets.js
CHANGED
package/cmd/utils/logger.js
CHANGED
|
@@ -107,12 +107,7 @@ class Logger {
|
|
|
107
107
|
|
|
108
108
|
async withSpinner(asyncFn, loadingMsg, successMsg, prefix, ...args) {
|
|
109
109
|
if (this.verbosity < 1 || DISABLE_SPINNER) {
|
|
110
|
-
|
|
111
|
-
return await asyncFn(...args);
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
throw (err);
|
|
115
|
-
}
|
|
110
|
+
return await asyncFn(...args);
|
|
116
111
|
}
|
|
117
112
|
|
|
118
113
|
this.spinner = ora({
|
package/cmd/utils/requests.js
CHANGED
|
@@ -327,10 +327,11 @@ async function getGritUserProject(privToken, region, user, projectName) {
|
|
|
327
327
|
};
|
|
328
328
|
const response = await axios(options);
|
|
329
329
|
switch (response.status) {
|
|
330
|
-
case 200:
|
|
330
|
+
case 200: {
|
|
331
331
|
const found = response.data?.find((entry) => entry['path'] === projectName);
|
|
332
332
|
if (!found) throw Error('GRIT user project not found');
|
|
333
333
|
return;
|
|
334
|
+
}
|
|
334
335
|
default:
|
|
335
336
|
throw Error('Get GRIT user project failed');
|
|
336
337
|
}
|
|
@@ -348,10 +349,11 @@ async function getGritGroup(privToken, region, groupName) {
|
|
|
348
349
|
};
|
|
349
350
|
const response = await axios(options);
|
|
350
351
|
switch (response.status) {
|
|
351
|
-
case 200:
|
|
352
|
+
case 200: {
|
|
352
353
|
const found = response.data?.find((entry) => entry['full_path'] === groupName);
|
|
353
354
|
if (!found) throw Error('GRIT group not found');
|
|
354
355
|
return found['id'];
|
|
356
|
+
}
|
|
355
357
|
default:
|
|
356
358
|
throw Error('Get GRIT group failed');
|
|
357
359
|
}
|
|
@@ -370,10 +372,11 @@ async function getGritGroupProject(privToken, region, groupId, projectName) {
|
|
|
370
372
|
};
|
|
371
373
|
const response = await axios(options);
|
|
372
374
|
switch (response.status) {
|
|
373
|
-
case 200:
|
|
375
|
+
case 200: {
|
|
374
376
|
const found = response.data?.find((entry) => entry['path'] === projectName);
|
|
375
377
|
if (!found) throw Error('GRIT group project not found');
|
|
376
378
|
return;
|
|
379
|
+
}
|
|
377
380
|
default:
|
|
378
381
|
throw Error('Get GRIT group project failed');
|
|
379
382
|
}
|
|
@@ -479,24 +482,24 @@ async function migrateToolchainSecrets(bearer, data, region) {
|
|
|
479
482
|
|
|
480
483
|
// GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
|
|
481
484
|
async function getWithRetry(client, path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
485
|
+
let lastError;
|
|
486
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
487
|
+
try {
|
|
488
|
+
return await client.get(path, { params });
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const status = error.response?.status;
|
|
491
|
+
if (attempt < retries && status && status >= 500) {
|
|
492
|
+
console.warn(
|
|
493
|
+
`[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
|
|
494
|
+
);
|
|
495
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
|
|
496
|
+
lastError = error;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
throw error; // Non-5xx or out of retries: rethrow
|
|
500
|
+
}
|
|
497
501
|
}
|
|
498
|
-
|
|
499
|
-
throw lastError;
|
|
502
|
+
throw lastError;
|
|
500
503
|
}
|
|
501
504
|
|
|
502
505
|
export {
|
package/cmd/utils/terraform.js
CHANGED
|
@@ -31,7 +31,7 @@ const writeFilePromise = promisify(fs.writeFile)
|
|
|
31
31
|
async function execPromise(command, options) {
|
|
32
32
|
try {
|
|
33
33
|
const exec = promisify(child_process.exec);
|
|
34
|
-
const { stdout,
|
|
34
|
+
const { stdout, _ } = await exec(command, options);
|
|
35
35
|
return stdout.trim();
|
|
36
36
|
} catch (err) {
|
|
37
37
|
throw new Error(`Command failed: ${command} \n${err.stderr || err.stdout}`);
|
|
@@ -419,7 +419,6 @@ async function runTerraformApply(skipTfConfirmation, outputDir, verbosity, targe
|
|
|
419
419
|
});
|
|
420
420
|
|
|
421
421
|
let stdoutData = '';
|
|
422
|
-
let stderrData = '';
|
|
423
422
|
|
|
424
423
|
child.stdout.on('data', (chunk) => {
|
|
425
424
|
const text = chunk.toString();
|
|
@@ -432,7 +431,6 @@ async function runTerraformApply(skipTfConfirmation, outputDir, verbosity, targe
|
|
|
432
431
|
|
|
433
432
|
child.stderr.on('data', (chunk) => {
|
|
434
433
|
const text = chunk.toString();
|
|
435
|
-
stderrData += text;
|
|
436
434
|
if (verbosity >= 1) {
|
|
437
435
|
process.stderr.write(text);
|
|
438
436
|
logger.dump(text);
|
package/cmd/utils/validate.js
CHANGED
|
@@ -86,8 +86,7 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
|
|
|
86
86
|
const toolchains = await getToolchainsByName(token, accountId, tcName);
|
|
87
87
|
|
|
88
88
|
let hasSameRegion = false;
|
|
89
|
-
let
|
|
90
|
-
let hasBoth = false;
|
|
89
|
+
let hasBoth = false; // same region and resource group
|
|
91
90
|
|
|
92
91
|
if (toolchains.length > 0) {
|
|
93
92
|
let newTcName = tcName;
|
|
@@ -100,8 +99,6 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
|
|
|
100
99
|
} else {
|
|
101
100
|
hasSameRegion = true;
|
|
102
101
|
}
|
|
103
|
-
} else if (tc.resource_group_id === targetResourceGroupId) {
|
|
104
|
-
hasSameResourceGroup = true;
|
|
105
102
|
}
|
|
106
103
|
});
|
|
107
104
|
|
|
@@ -117,10 +114,6 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
|
|
|
117
114
|
// soft warning of confusion
|
|
118
115
|
logger.warn(`\nWarning! A toolchain named '${tcName}' already exists in:\n - Region: ${targetRegion}`, '', true);
|
|
119
116
|
}
|
|
120
|
-
// if (hasSameResourceGroup) {
|
|
121
|
-
// // soft warning of confusion
|
|
122
|
-
// logger.warn(`\nWarning! A toolchain named '${tcName}' already exists in:\n - Resource Group: ${targetResourceGroupName} (${targetResourceGroupId})`, '', true);
|
|
123
|
-
// }
|
|
124
117
|
}
|
|
125
118
|
|
|
126
119
|
if (hasBoth || hasSameRegion) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Licensed Materials - Property of IBM
|
|
3
|
-
* (c) Copyright IBM Corporation 2025. All Rights Reserved.
|
|
3
|
+
* (c) Copyright IBM Corporation 2025, 2026. All Rights Reserved.
|
|
4
4
|
*
|
|
5
5
|
* Note to U.S. Government Users Restricted Rights:
|
|
6
6
|
* Use, duplication or disclosure restricted by GSA ADP Schedule
|
|
@@ -15,7 +15,7 @@ import { expect, assert } from 'chai';
|
|
|
15
15
|
|
|
16
16
|
import { assertPtyOutput, assertExecError, areFilesInDir, deleteCreatedToolchains, parseTcIdAndRegion } from '../utils/testUtils.js';
|
|
17
17
|
import { getBearerToken, getToolchain } from '../../cmd/utils/requests.js';
|
|
18
|
-
import { TEST_TOOLCHAINS, DEFAULT_RG_ID
|
|
18
|
+
import { TEST_TOOLCHAINS, DEFAULT_RG_ID } from '../data/test-toolchains.js';
|
|
19
19
|
import { TARGET_REGIONS } from '../../config.js';
|
|
20
20
|
|
|
21
21
|
nconf.env('__');
|
|
@@ -53,16 +53,17 @@ describe('copy-toolchain: Test functionalities', function () {
|
|
|
53
53
|
timeout: 10000
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
56
|
+
// TODO: update outdated test from when missing cd instance would fail the command
|
|
57
|
+
// {
|
|
58
|
+
// name: 'Check if CD instance exists in target region',
|
|
59
|
+
// cmd: [CLI_PATH, COMMAND, '-c', TEST_TOOLCHAINS['empty'].crn, '-r', TARGET_REGIONS[0]],
|
|
60
|
+
// expected: new RegExp(`Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`),
|
|
61
|
+
// options: {
|
|
62
|
+
// exitCondition: `Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`,
|
|
63
|
+
// timeout: 10000,
|
|
64
|
+
// env: { ...process.env, MOCK_ALL_REQUESTS: 'true', MOCK_GET_CD_INSTANCE_BY_REGION_SCENARIO: 'NOT_FOUND' }
|
|
65
|
+
// }
|
|
66
|
+
// },
|
|
66
67
|
{
|
|
67
68
|
name: 'Log file is created successfully',
|
|
68
69
|
cmd: [CLI_PATH, COMMAND, '-c', TEST_TOOLCHAINS['empty'].crn, '-r', TEST_TOOLCHAINS['empty'].region],
|