@icedq/cli 0.1.3 → 0.2.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/LICENSE +201 -21
- package/NOTICE +16 -0
- package/package.json +48 -43
- package/src/bin/icedq.js +9 -0
- package/src/commands/export.js +102 -102
- package/src/commands/generate-mapping.js +217 -0
- package/src/commands/import.js +174 -174
- package/src/core/client.js +183 -183
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from '../core/config.js';
|
|
4
|
+
import { KeycloakClientCredentialsAuth } from '../core/auth.js';
|
|
5
|
+
import { IcedqApiClient } from '../core/client.js';
|
|
6
|
+
import { Reporter } from '../core/reporter.js';
|
|
7
|
+
import { ApiError, CliError } from '../core/errors.js';
|
|
8
|
+
import { log, setLevel } from '../core/logger.js';
|
|
9
|
+
|
|
10
|
+
const PAGE_SIZE = 1000;
|
|
11
|
+
|
|
12
|
+
export async function runGenerateMapping(rawOpts) {
|
|
13
|
+
if (rawOpts.verbose) setLevel('debug');
|
|
14
|
+
if (rawOpts.quiet) setLevel('error');
|
|
15
|
+
|
|
16
|
+
if (!rawOpts.bundle) throw new CliError('--bundle is required');
|
|
17
|
+
if (!rawOpts.outputFile) throw new CliError('--output-file is required');
|
|
18
|
+
|
|
19
|
+
const cfg = loadConfig(rawOpts);
|
|
20
|
+
const auth = new KeycloakClientCredentialsAuth({
|
|
21
|
+
keycloakUrl: cfg.keycloakUrl,
|
|
22
|
+
clientId: cfg.clientId,
|
|
23
|
+
clientSecret: cfg.clientSecret,
|
|
24
|
+
verifySsl: cfg.verifySsl
|
|
25
|
+
});
|
|
26
|
+
const client = new IcedqApiClient({
|
|
27
|
+
baseUrl: cfg.icedqUrl,
|
|
28
|
+
orgId: cfg.orgId,
|
|
29
|
+
accountId: cfg.accountId,
|
|
30
|
+
workspaceId: cfg.workspaceId,
|
|
31
|
+
auth,
|
|
32
|
+
verifySsl: cfg.verifySsl
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const bundlePath = path.resolve(rawOpts.bundle);
|
|
36
|
+
let bundleBuffer;
|
|
37
|
+
try {
|
|
38
|
+
bundleBuffer = await readFile(bundlePath);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw new CliError(`could not read bundle at ${bundlePath}: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Step 1: get expected mappings from bundle
|
|
44
|
+
log.info('Uploading bundle to get expected mappings');
|
|
45
|
+
const expected = await client.postMultipart('/api/v1/internal/imports/mapping', {
|
|
46
|
+
file: {
|
|
47
|
+
buffer: bundleBuffer,
|
|
48
|
+
filename: path.basename(bundlePath),
|
|
49
|
+
contentType: 'application/zip'
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const srcConnections = expected.connections || [];
|
|
54
|
+
const srcParameters = expected.parameters || [];
|
|
55
|
+
const srcCustomFields = expected.customFields || [];
|
|
56
|
+
|
|
57
|
+
log.info('Expected mappings', {
|
|
58
|
+
connections: srcConnections.length,
|
|
59
|
+
parameters: srcParameters.length,
|
|
60
|
+
customFields: srcCustomFields.length
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Step 2: resolve connections from target environment
|
|
64
|
+
const connections = await resolveConnections(client, srcConnections);
|
|
65
|
+
|
|
66
|
+
// Step 3: resolve parameters from target environment
|
|
67
|
+
const parameters = await resolveParameters(client, srcParameters);
|
|
68
|
+
|
|
69
|
+
// Step 4: resolve custom fields from target environment
|
|
70
|
+
const customFields = await resolveCustomFields(client, srcCustomFields);
|
|
71
|
+
|
|
72
|
+
const mappingDoc = {
|
|
73
|
+
useFqn: true,
|
|
74
|
+
mapping: { connections, parameters, customFields }
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const outputFile = path.resolve(rawOpts.outputFile);
|
|
78
|
+
await mkdir(path.dirname(outputFile), { recursive: true });
|
|
79
|
+
await writeFile(outputFile, JSON.stringify(mappingDoc, null, 2), 'utf8');
|
|
80
|
+
log.info('Mapping file written', { outputFile });
|
|
81
|
+
|
|
82
|
+
const result = {
|
|
83
|
+
command: 'generate-mapping',
|
|
84
|
+
outputFile,
|
|
85
|
+
connections: connections.length,
|
|
86
|
+
parameters: parameters.length,
|
|
87
|
+
customFields: customFields.length
|
|
88
|
+
};
|
|
89
|
+
new Reporter(rawOpts.output || 'text').emit(result);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function resolveConnections(client, srcConnections) {
|
|
93
|
+
if (srcConnections.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
// Group source connections by connectorId
|
|
96
|
+
const byConnectorId = new Map();
|
|
97
|
+
for (const conn of srcConnections) {
|
|
98
|
+
if (!byConnectorId.has(conn.connectorId)) byConnectorId.set(conn.connectorId, []);
|
|
99
|
+
byConnectorId.get(conn.connectorId).push(conn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mappings = [];
|
|
103
|
+
|
|
104
|
+
for (const [connectorId, srcConns] of byConnectorId) {
|
|
105
|
+
log.info('Searching connections in target', { connectorId });
|
|
106
|
+
|
|
107
|
+
// Fetch all pages for this connectorId
|
|
108
|
+
const targetItems = await fetchAllPages(client, '/api/v1/connections/search', {
|
|
109
|
+
filter: [{ attribute: 'connectorId', operator: 'In', datatype: 'string', value: connectorId }]
|
|
110
|
+
}, `connections with connectorId=${connectorId}`);
|
|
111
|
+
|
|
112
|
+
for (const src of srcConns) {
|
|
113
|
+
const match = targetItems.find(
|
|
114
|
+
(t) => t.name.toLowerCase() === src.name.toLowerCase()
|
|
115
|
+
);
|
|
116
|
+
if (!match) {
|
|
117
|
+
throw new CliError(
|
|
118
|
+
`Connection "${src.name}" (connectorId: ${connectorId}) was not found in the target environment. ` +
|
|
119
|
+
`Ensure a connection with this name exists in the target workspace before generating the mapping.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
mappings.push({ existingId: src.id, newId: match.id, action: 'override' });
|
|
123
|
+
log.info('Mapped connection', { name: src.name, existingId: src.id, newId: match.id });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return mappings;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function resolveParameters(client, srcParameters) {
|
|
131
|
+
if (srcParameters.length === 0) return [];
|
|
132
|
+
|
|
133
|
+
log.info('Searching parameters in target');
|
|
134
|
+
|
|
135
|
+
let targetItems = [];
|
|
136
|
+
try {
|
|
137
|
+
targetItems = await fetchAllPages(client, '/api/v1/parameters/search', {}, 'parameters');
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof ApiError && err.code === 'ResultsNotFound') {
|
|
140
|
+
log.warn('No parameters found in target environment — mapping without newId');
|
|
141
|
+
return srcParameters.map((p) => ({ existingId: p.id, action: 'upsert' }));
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return srcParameters.map((src) => {
|
|
147
|
+
const match = targetItems.find(
|
|
148
|
+
(t) => t.name.toLowerCase() === src.name.toLowerCase()
|
|
149
|
+
);
|
|
150
|
+
if (!match) {
|
|
151
|
+
log.warn('Parameter not matched in target, mapping without newId', { name: src.name });
|
|
152
|
+
return { existingId: src.id, action: 'upsert' };
|
|
153
|
+
}
|
|
154
|
+
log.info('Mapped parameter', { name: src.name, existingId: src.id, newId: match.id });
|
|
155
|
+
return { existingId: src.id, newId: match.id, action: 'upsert' };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function resolveCustomFields(client, srcCustomFields) {
|
|
160
|
+
if (srcCustomFields.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
log.info('Fetching screens for custom field mapping');
|
|
163
|
+
const screens = await client.post('/api/v1/screens/default/search', ['rule', 'check']);
|
|
164
|
+
|
|
165
|
+
// Flatten all fields from all sections of all screens into a name→id map
|
|
166
|
+
const fieldMap = new Map();
|
|
167
|
+
for (const screen of Array.isArray(screens) ? screens : []) {
|
|
168
|
+
const sections = screen?.info?.sections || [];
|
|
169
|
+
for (const section of sections) {
|
|
170
|
+
for (const field of section.fields || []) {
|
|
171
|
+
if (field.name && field.id) {
|
|
172
|
+
fieldMap.set(field.name.toLowerCase(), field.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const mappings = [];
|
|
179
|
+
for (const fieldName of srcCustomFields) {
|
|
180
|
+
const targetId = fieldMap.get(fieldName.toLowerCase());
|
|
181
|
+
if (!targetId) {
|
|
182
|
+
log.warn('Custom field not found in target screens, skipping', { fieldName });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
mappings.push({ existingId: fieldName, newId: targetId, action: 'override' });
|
|
186
|
+
log.info('Mapped custom field', { fieldName, newId: targetId });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return mappings;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fetches all pages from a paginated POST search endpoint.
|
|
193
|
+
// Throws ApiError with code='ResultsNotFound' when the endpoint reports nothing found.
|
|
194
|
+
async function fetchAllPages(client, endpoint, body, label) {
|
|
195
|
+
const items = [];
|
|
196
|
+
let pageNo = 1;
|
|
197
|
+
let totalPages = 1;
|
|
198
|
+
|
|
199
|
+
do {
|
|
200
|
+
const url = `${endpoint}?pageNo=${pageNo}&pageSize=${PAGE_SIZE}&sort=updatedTimestamp:desc`;
|
|
201
|
+
const resp = await client.post(url, body);
|
|
202
|
+
|
|
203
|
+
// API may return 200 with {code, message} instead of a 4xx
|
|
204
|
+
if (resp && resp.code === 'ResultsNotFound') {
|
|
205
|
+
throw new ApiError(`${label}: ${resp.message || 'ResultsNotFound'}`, {
|
|
206
|
+
status: 200,
|
|
207
|
+
code: 'ResultsNotFound'
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
items.push(...(resp?.items || []));
|
|
212
|
+
totalPages = resp?.pageable?.pages ?? 1;
|
|
213
|
+
pageNo++;
|
|
214
|
+
} while (pageNo <= totalPages);
|
|
215
|
+
|
|
216
|
+
return items;
|
|
217
|
+
}
|
package/src/commands/import.js
CHANGED
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { loadConfig } from '../core/config.js';
|
|
4
|
-
import { KeycloakClientCredentialsAuth } from '../core/auth.js';
|
|
5
|
-
import { IcedqApiClient } from '../core/client.js';
|
|
6
|
-
import { pollTask } from '../core/poller.js';
|
|
7
|
-
import { Reporter } from '../core/reporter.js';
|
|
8
|
-
import { parseImportLog } from '../lib/log-parser.js';
|
|
9
|
-
import { isSuccess } from '../lib/status-enum.js';
|
|
10
|
-
import { ApiError, BundleError, CliError, TaskFailedError } from '../core/errors.js';
|
|
11
|
-
import { log, setLevel } from '../core/logger.js';
|
|
12
|
-
|
|
13
|
-
const VALID_KINDS = new Set(['rules', 'workflows']);
|
|
14
|
-
|
|
15
|
-
export async function runImport(rawOpts) {
|
|
16
|
-
if (rawOpts.verbose) setLevel('debug');
|
|
17
|
-
if (rawOpts.quiet) setLevel('error');
|
|
18
|
-
|
|
19
|
-
if (!rawOpts.bundle) throw new CliError('--bundle is required');
|
|
20
|
-
if (!rawOpts.kind || !VALID_KINDS.has(rawOpts.kind)) {
|
|
21
|
-
throw new CliError(`--kind must be one of: rules, workflows`);
|
|
22
|
-
}
|
|
23
|
-
if (!rawOpts.mappingFile) {
|
|
24
|
-
throw new CliError(
|
|
25
|
-
'--mapping-file is required in v0.1. Auto-mapping (`generate-mapping`) ships in v0.2.'
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const cfg = loadConfig(rawOpts);
|
|
30
|
-
const auth = new KeycloakClientCredentialsAuth({
|
|
31
|
-
keycloakUrl: cfg.keycloakUrl,
|
|
32
|
-
clientId: cfg.clientId,
|
|
33
|
-
clientSecret: cfg.clientSecret,
|
|
34
|
-
verifySsl: cfg.verifySsl
|
|
35
|
-
});
|
|
36
|
-
const client = new IcedqApiClient({
|
|
37
|
-
baseUrl: cfg.icedqUrl,
|
|
38
|
-
orgId: cfg.orgId,
|
|
39
|
-
accountId: cfg.accountId,
|
|
40
|
-
workspaceId: cfg.workspaceId,
|
|
41
|
-
auth,
|
|
42
|
-
verifySsl: cfg.verifySsl
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const bundlePath = path.resolve(rawOpts.bundle);
|
|
46
|
-
const mappingPath = path.resolve(rawOpts.mappingFile);
|
|
47
|
-
|
|
48
|
-
let bundleBuffer;
|
|
49
|
-
try {
|
|
50
|
-
bundleBuffer = await readFile(bundlePath);
|
|
51
|
-
} catch (err) {
|
|
52
|
-
throw new BundleError(`could not read bundle at ${bundlePath}: ${err.message}`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let mappingDoc;
|
|
56
|
-
try {
|
|
57
|
-
const text = await readFile(mappingPath, 'utf8');
|
|
58
|
-
mappingDoc = JSON.parse(text);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
throw new CliError(`could not parse --mapping-file at ${mappingPath}: ${err.message}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (mappingDoc.useFqn === undefined) {
|
|
64
|
-
mappingDoc.useFqn = !!rawOpts.useFqn;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const submitParts = {
|
|
68
|
-
file: {
|
|
69
|
-
buffer: bundleBuffer,
|
|
70
|
-
filename: path.basename(bundlePath),
|
|
71
|
-
contentType: 'application/zip'
|
|
72
|
-
},
|
|
73
|
-
mapping: { json: mappingDoc }
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const importPath = `/api/v1/imports/${rawOpts.kind}`;
|
|
77
|
-
log.info('Submitting import', { kind: rawOpts.kind, bundle: bundlePath, mapping: mappingPath });
|
|
78
|
-
|
|
79
|
-
let submitResp;
|
|
80
|
-
try {
|
|
81
|
-
submitResp = await client.postMultipart(importPath, submitParts);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
if (err instanceof ApiError && err.isConstraintViolation()) {
|
|
84
|
-
const result = {
|
|
85
|
-
command: 'import',
|
|
86
|
-
status: 'ConstraintViolation',
|
|
87
|
-
hardErrors: err.messages.map(
|
|
88
|
-
(m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`
|
|
89
|
-
)
|
|
90
|
-
};
|
|
91
|
-
new Reporter(rawOpts.output || 'text').emit(result);
|
|
92
|
-
throw err;
|
|
93
|
-
}
|
|
94
|
-
if (err instanceof ApiError && err.status === 409 && rawOpts.terminateOnConflict) {
|
|
95
|
-
log.warn('Active import job conflicts; locating and terminating');
|
|
96
|
-
await terminateActiveImport(client);
|
|
97
|
-
submitResp = await client.postMultipart(importPath, submitParts);
|
|
98
|
-
} else {
|
|
99
|
-
throw err;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const taskId = submitResp.taskInstanceId;
|
|
104
|
-
if (!taskId) throw new CliError(`Import submit did not return taskInstanceId: ${JSON.stringify(submitResp)}`);
|
|
105
|
-
process.stderr.write(`task-id: ${taskId}\n`);
|
|
106
|
-
|
|
107
|
-
const start = Date.now();
|
|
108
|
-
const { status, elapsedMs, attempts } = await pollTask(client, 'imports', taskId, {
|
|
109
|
-
timeoutSec: cfg.timeoutSec,
|
|
110
|
-
onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
let logText = '';
|
|
114
|
-
try {
|
|
115
|
-
logText = await client.get(`/api/v1/imports/${taskId}/log`);
|
|
116
|
-
if (typeof logText !== 'string') logText = JSON.stringify(logText);
|
|
117
|
-
} catch (err) {
|
|
118
|
-
log.warn('Could not retrieve import log', { error: err.message });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (rawOpts.retainLog && logText) {
|
|
122
|
-
const logPath = path.resolve(rawOpts.retainLog);
|
|
123
|
-
try {
|
|
124
|
-
await writeFile(logPath, logText, 'utf8');
|
|
125
|
-
log.info('Import log retained', { logPath });
|
|
126
|
-
} catch (err) {
|
|
127
|
-
log.warn('Could not write retained log', { logPath, error: err.message });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const parsed = parseImportLog(logText);
|
|
132
|
-
const result = {
|
|
133
|
-
command: 'import',
|
|
134
|
-
taskId,
|
|
135
|
-
status,
|
|
136
|
-
attempts,
|
|
137
|
-
durationMs: elapsedMs,
|
|
138
|
-
skippedCount: parsed.skippedCount,
|
|
139
|
-
skippedRules: parsed.skippedRules,
|
|
140
|
-
hardErrors: parsed.hardErrors,
|
|
141
|
-
elapsedSinceSubmitMs: Date.now() - start
|
|
142
|
-
};
|
|
143
|
-
new Reporter(rawOpts.output || 'text').emit(result);
|
|
144
|
-
|
|
145
|
-
if (!isSuccess(status)) {
|
|
146
|
-
throw new TaskFailedError(taskId, 'import', status);
|
|
147
|
-
}
|
|
148
|
-
if (rawOpts.strict && parsed.skippedCount > 0) {
|
|
149
|
-
const e = new CliError(
|
|
150
|
-
`--strict: import completed but ${parsed.skippedCount} rule(s) skipped. See log for details.`
|
|
151
|
-
);
|
|
152
|
-
e.exitCode = 1;
|
|
153
|
-
throw e;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function terminateActiveImport(client) {
|
|
158
|
-
const search = await client.post('/api/v1/taskruns/search', {
|
|
159
|
-
filter: [
|
|
160
|
-
{ attribute: 'type', operator: 'In', value: 'import-rules' },
|
|
161
|
-
{ attribute: 'type', operator: 'In', value: 'import-workflows' }
|
|
162
|
-
]
|
|
163
|
-
});
|
|
164
|
-
const active = (search?.items || search?.taskRuns || []).find((r) =>
|
|
165
|
-
['Submitted', 'Running'].includes(r.taskStatus || r.status)
|
|
166
|
-
);
|
|
167
|
-
if (!active) {
|
|
168
|
-
log.warn('No active import found to terminate');
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const id = active.id || active.taskInstanceId;
|
|
172
|
-
log.info('Terminating active import', { taskId: id });
|
|
173
|
-
await client.post(`/api/v1/imports/${id}:terminate`, {});
|
|
174
|
-
}
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from '../core/config.js';
|
|
4
|
+
import { KeycloakClientCredentialsAuth } from '../core/auth.js';
|
|
5
|
+
import { IcedqApiClient } from '../core/client.js';
|
|
6
|
+
import { pollTask } from '../core/poller.js';
|
|
7
|
+
import { Reporter } from '../core/reporter.js';
|
|
8
|
+
import { parseImportLog } from '../lib/log-parser.js';
|
|
9
|
+
import { isSuccess } from '../lib/status-enum.js';
|
|
10
|
+
import { ApiError, BundleError, CliError, TaskFailedError } from '../core/errors.js';
|
|
11
|
+
import { log, setLevel } from '../core/logger.js';
|
|
12
|
+
|
|
13
|
+
const VALID_KINDS = new Set(['rules', 'workflows']);
|
|
14
|
+
|
|
15
|
+
export async function runImport(rawOpts) {
|
|
16
|
+
if (rawOpts.verbose) setLevel('debug');
|
|
17
|
+
if (rawOpts.quiet) setLevel('error');
|
|
18
|
+
|
|
19
|
+
if (!rawOpts.bundle) throw new CliError('--bundle is required');
|
|
20
|
+
if (!rawOpts.kind || !VALID_KINDS.has(rawOpts.kind)) {
|
|
21
|
+
throw new CliError(`--kind must be one of: rules, workflows`);
|
|
22
|
+
}
|
|
23
|
+
if (!rawOpts.mappingFile) {
|
|
24
|
+
throw new CliError(
|
|
25
|
+
'--mapping-file is required in v0.1. Auto-mapping (`generate-mapping`) ships in v0.2.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cfg = loadConfig(rawOpts);
|
|
30
|
+
const auth = new KeycloakClientCredentialsAuth({
|
|
31
|
+
keycloakUrl: cfg.keycloakUrl,
|
|
32
|
+
clientId: cfg.clientId,
|
|
33
|
+
clientSecret: cfg.clientSecret,
|
|
34
|
+
verifySsl: cfg.verifySsl
|
|
35
|
+
});
|
|
36
|
+
const client = new IcedqApiClient({
|
|
37
|
+
baseUrl: cfg.icedqUrl,
|
|
38
|
+
orgId: cfg.orgId,
|
|
39
|
+
accountId: cfg.accountId,
|
|
40
|
+
workspaceId: cfg.workspaceId,
|
|
41
|
+
auth,
|
|
42
|
+
verifySsl: cfg.verifySsl
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const bundlePath = path.resolve(rawOpts.bundle);
|
|
46
|
+
const mappingPath = path.resolve(rawOpts.mappingFile);
|
|
47
|
+
|
|
48
|
+
let bundleBuffer;
|
|
49
|
+
try {
|
|
50
|
+
bundleBuffer = await readFile(bundlePath);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new BundleError(`could not read bundle at ${bundlePath}: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let mappingDoc;
|
|
56
|
+
try {
|
|
57
|
+
const text = await readFile(mappingPath, 'utf8');
|
|
58
|
+
mappingDoc = JSON.parse(text);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new CliError(`could not parse --mapping-file at ${mappingPath}: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mappingDoc.useFqn === undefined) {
|
|
64
|
+
mappingDoc.useFqn = !!rawOpts.useFqn;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const submitParts = {
|
|
68
|
+
file: {
|
|
69
|
+
buffer: bundleBuffer,
|
|
70
|
+
filename: path.basename(bundlePath),
|
|
71
|
+
contentType: 'application/zip'
|
|
72
|
+
},
|
|
73
|
+
mapping: { json: mappingDoc }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const importPath = `/api/v1/imports/${rawOpts.kind}`;
|
|
77
|
+
log.info('Submitting import', { kind: rawOpts.kind, bundle: bundlePath, mapping: mappingPath });
|
|
78
|
+
|
|
79
|
+
let submitResp;
|
|
80
|
+
try {
|
|
81
|
+
submitResp = await client.postMultipart(importPath, submitParts);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof ApiError && err.isConstraintViolation()) {
|
|
84
|
+
const result = {
|
|
85
|
+
command: 'import',
|
|
86
|
+
status: 'ConstraintViolation',
|
|
87
|
+
hardErrors: err.messages.map(
|
|
88
|
+
(m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`
|
|
89
|
+
)
|
|
90
|
+
};
|
|
91
|
+
new Reporter(rawOpts.output || 'text').emit(result);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof ApiError && err.status === 409 && rawOpts.terminateOnConflict) {
|
|
95
|
+
log.warn('Active import job conflicts; locating and terminating');
|
|
96
|
+
await terminateActiveImport(client);
|
|
97
|
+
submitResp = await client.postMultipart(importPath, submitParts);
|
|
98
|
+
} else {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const taskId = submitResp.taskInstanceId;
|
|
104
|
+
if (!taskId) throw new CliError(`Import submit did not return taskInstanceId: ${JSON.stringify(submitResp)}`);
|
|
105
|
+
process.stderr.write(`task-id: ${taskId}\n`);
|
|
106
|
+
|
|
107
|
+
const start = Date.now();
|
|
108
|
+
const { status, elapsedMs, attempts } = await pollTask(client, 'imports', taskId, {
|
|
109
|
+
timeoutSec: cfg.timeoutSec,
|
|
110
|
+
onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let logText = '';
|
|
114
|
+
try {
|
|
115
|
+
logText = await client.get(`/api/v1/imports/${taskId}/log`);
|
|
116
|
+
if (typeof logText !== 'string') logText = JSON.stringify(logText);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.warn('Could not retrieve import log', { error: err.message });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (rawOpts.retainLog && logText) {
|
|
122
|
+
const logPath = path.resolve(rawOpts.retainLog);
|
|
123
|
+
try {
|
|
124
|
+
await writeFile(logPath, logText, 'utf8');
|
|
125
|
+
log.info('Import log retained', { logPath });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
log.warn('Could not write retained log', { logPath, error: err.message });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const parsed = parseImportLog(logText);
|
|
132
|
+
const result = {
|
|
133
|
+
command: 'import',
|
|
134
|
+
taskId,
|
|
135
|
+
status,
|
|
136
|
+
attempts,
|
|
137
|
+
durationMs: elapsedMs,
|
|
138
|
+
skippedCount: parsed.skippedCount,
|
|
139
|
+
skippedRules: parsed.skippedRules,
|
|
140
|
+
hardErrors: parsed.hardErrors,
|
|
141
|
+
elapsedSinceSubmitMs: Date.now() - start
|
|
142
|
+
};
|
|
143
|
+
new Reporter(rawOpts.output || 'text').emit(result);
|
|
144
|
+
|
|
145
|
+
if (!isSuccess(status)) {
|
|
146
|
+
throw new TaskFailedError(taskId, 'import', status);
|
|
147
|
+
}
|
|
148
|
+
if (rawOpts.strict && parsed.skippedCount > 0) {
|
|
149
|
+
const e = new CliError(
|
|
150
|
+
`--strict: import completed but ${parsed.skippedCount} rule(s) skipped. See log for details.`
|
|
151
|
+
);
|
|
152
|
+
e.exitCode = 1;
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function terminateActiveImport(client) {
|
|
158
|
+
const search = await client.post('/api/v1/taskruns/search', {
|
|
159
|
+
filter: [
|
|
160
|
+
{ attribute: 'type', operator: 'In', value: 'import-rules' },
|
|
161
|
+
{ attribute: 'type', operator: 'In', value: 'import-workflows' }
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
const active = (search?.items || search?.taskRuns || []).find((r) =>
|
|
165
|
+
['Submitted', 'Running'].includes(r.taskStatus || r.status)
|
|
166
|
+
);
|
|
167
|
+
if (!active) {
|
|
168
|
+
log.warn('No active import found to terminate');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const id = active.id || active.taskInstanceId;
|
|
172
|
+
log.info('Terminating active import', { taskId: id });
|
|
173
|
+
await client.post(`/api/v1/imports/${id}:terminate`, {});
|
|
174
|
+
}
|