@faros-fde-sandbox/airbyte-local-cli 0.1.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/DEVELOPER.md +76 -0
- package/LICENSE +201 -0
- package/README.md +440 -0
- package/lib/command.d.ts +11 -0
- package/lib/command.js +261 -0
- package/lib/command.js.map +1 -0
- package/lib/constants/airbyteConfig.d.ts +16 -0
- package/lib/constants/airbyteConfig.js +85 -0
- package/lib/constants/airbyteConfig.js.map +1 -0
- package/lib/constants/airbyteTypes.d.ts +14 -0
- package/lib/constants/airbyteTypes.js +214 -0
- package/lib/constants/airbyteTypes.js.map +1 -0
- package/lib/constants/constants.d.ts +11 -0
- package/lib/constants/constants.js +16 -0
- package/lib/constants/constants.js.map +1 -0
- package/lib/docker.d.ts +84 -0
- package/lib/docker.js +705 -0
- package/lib/docker.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +89 -0
- package/lib/index.js.map +1 -0
- package/lib/logger.d.ts +2 -0
- package/lib/logger.js +11 -0
- package/lib/logger.js.map +1 -0
- package/lib/types.d.ts +178 -0
- package/lib/types.js +63 -0
- package/lib/types.js.map +1 -0
- package/lib/utils.d.ts +105 -0
- package/lib/utils.js +732 -0
- package/lib/utils.js.map +1 -0
- package/lib/version.d.ts +1 -0
- package/lib/version.js +5 -0
- package/lib/version.js.map +1 -0
- package/package.json +84 -0
- package/scripts/lint +37 -0
- package/src/command.ts +304 -0
- package/src/constants/airbyteConfig.ts +96 -0
- package/src/constants/airbyteTypes.ts +226 -0
- package/src/constants/constants.ts +12 -0
- package/src/docker.ts +757 -0
- package/src/index.ts +109 -0
- package/src/logger.ts +5 -0
- package/src/tsconfig.json +6 -0
- package/src/types.ts +204 -0
- package/src/utils.ts +827 -0
- package/src/version.ts +1 -0
- package/test/__snapshots__/docker.it.test.ts.snap +422 -0
- package/test/__snapshots__/utils.it.test.ts.snap +365 -0
- package/test/command.test.ts +315 -0
- package/test/docker.it.test.ts +330 -0
- package/test/docker.test.ts +119 -0
- package/test/exec/.shellspec +13 -0
- package/test/exec/spec/index_spec.sh +441 -0
- package/test/exec/spec/spec_helper.sh +24 -0
- package/test/index.test.ts +158 -0
- package/test/pester/index.Tests.ps1 +315 -0
- package/test/resources/databricks_spec.json +145 -0
- package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_dst_catalog.json +16 -0
- package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_dst_config.json.template +9 -0
- package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_src_output +51 -0
- package/test/resources/dockerIt_runSrcSync/faros_airbyte_cli_src_catalog.json +1 -0
- package/test/resources/dockerIt_runSrcSync/faros_airbyte_cli_src_config.json +3 -0
- package/test/resources/dockerIt_runSrcSync/state.json +1 -0
- package/test/resources/faros_airbyte_cli_src_config.json +8 -0
- package/test/resources/faros_airbyte_cli_src_config_chris.json +3 -0
- package/test/resources/faros_airbyte_cli_src_config_jennie.json +3 -0
- package/test/resources/graphql_spec.json +103 -0
- package/test/resources/test__state.json +1 -0
- package/test/resources/test__state_utf16.json +0 -0
- package/test/resources/test_config_file.json +38 -0
- package/test/resources/test_config_file_crlf.json +38 -0
- package/test/resources/test_config_file_dst_only.json.template +25 -0
- package/test/resources/test_config_file_graph_copy.json.template +25 -0
- package/test/resources/test_config_file_invalid +5 -0
- package/test/resources/test_config_file_src_only space.json +8 -0
- package/test/resources/test_config_file_src_only.json +8 -0
- package/test/resources/test_config_file_utf16.json +0 -0
- package/test/resources/test_src_input +9 -0
- package/test/resources/test_src_input_invalid_json +1 -0
- package/test/tsconfig.json +12 -0
- package/test/utils.it.test.ts +605 -0
- package/test/utils.test.ts +448 -0
- package/tsconfig.json +14 -0
package/src/docker.ts
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import {createReadStream, createWriteStream, readFileSync, ReadStream, writeFileSync} from 'node:fs';
|
|
2
|
+
import {sep} from 'node:path';
|
|
3
|
+
import {PassThrough, Writable} from 'node:stream';
|
|
4
|
+
|
|
5
|
+
import Docker from 'dockerode';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_STATE_FILE,
|
|
9
|
+
DST_CATALOG_FILENAME,
|
|
10
|
+
DST_CONFIG_FILENAME,
|
|
11
|
+
SRC_CATALOG_FILENAME,
|
|
12
|
+
SRC_CONFIG_FILENAME,
|
|
13
|
+
SRC_OUTPUT_DATA_FILE,
|
|
14
|
+
TMP_SPEC_CONFIG_FILENAME,
|
|
15
|
+
TMP_WIZARD_CONFIG_FILENAME,
|
|
16
|
+
} from './constants/constants';
|
|
17
|
+
// Self-reference for unit test mocking
|
|
18
|
+
import * as docker from './docker';
|
|
19
|
+
import {logger} from './logger';
|
|
20
|
+
import {
|
|
21
|
+
AirbyteCatalog,
|
|
22
|
+
AirbyteCatalogMessage,
|
|
23
|
+
AirbyteConnectionStatus,
|
|
24
|
+
AirbyteConnectionStatusMessage,
|
|
25
|
+
AirbyteMessageType,
|
|
26
|
+
AirbyteSpec,
|
|
27
|
+
AirbyteState,
|
|
28
|
+
AirbyteStateMessage,
|
|
29
|
+
FarosConfig,
|
|
30
|
+
OutputStream,
|
|
31
|
+
} from './types';
|
|
32
|
+
import {
|
|
33
|
+
collectStates,
|
|
34
|
+
extractStateFromMessage,
|
|
35
|
+
formatDstMsg,
|
|
36
|
+
getUserAgent,
|
|
37
|
+
logDstMessage,
|
|
38
|
+
processSrcDataByLine,
|
|
39
|
+
writeStateFile,
|
|
40
|
+
} from './utils';
|
|
41
|
+
|
|
42
|
+
// Constants
|
|
43
|
+
const DEFAULT_MAX_LOG_SIZE = '10m';
|
|
44
|
+
|
|
45
|
+
// Create a new Docker instance
|
|
46
|
+
let _docker = new Docker();
|
|
47
|
+
|
|
48
|
+
// Track running containers for cleanup on exit
|
|
49
|
+
const runningContainers = new Set<string>();
|
|
50
|
+
|
|
51
|
+
// For testing purpose only
|
|
52
|
+
export function setDocker(testDocker: Docker): void {
|
|
53
|
+
_docker = testDocker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// For testing purpose only
|
|
57
|
+
export function addRunningContainerForTest(containerId: string): void {
|
|
58
|
+
runningContainers.add(containerId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function stopAllContainers(): Promise<void> {
|
|
62
|
+
if (runningContainers.size > 0) {
|
|
63
|
+
const containers = [...runningContainers];
|
|
64
|
+
await Promise.all(
|
|
65
|
+
containers.map(async (containerId) => {
|
|
66
|
+
try {
|
|
67
|
+
await _docker.getContainer(containerId).stop();
|
|
68
|
+
logger.debug(`Container ${containerId} stopped.`);
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
// Ignore 304 "container already stopped" - this is expected during cleanup
|
|
71
|
+
if (error.statusCode === 304) {
|
|
72
|
+
logger.debug(`Container ${containerId} already stopped.`);
|
|
73
|
+
} else {
|
|
74
|
+
logger.warn(`Failed to stop container ${containerId}: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
runningContainers.clear();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Use 'linux/amd64' plaform for farosai images.
|
|
85
|
+
* Use 'windows/amd64' platform if there's `windows` in the tag.
|
|
86
|
+
*
|
|
87
|
+
* TODO: @FAI-15309 This should be removed once we have a proper multi-platform image.
|
|
88
|
+
*/
|
|
89
|
+
function getImagePlatform(image: string): string | undefined {
|
|
90
|
+
if (image.includes(':windows')) {
|
|
91
|
+
return 'windows/amd64';
|
|
92
|
+
} else if (image?.startsWith('farosai')) {
|
|
93
|
+
return 'linux/amd64';
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* This is a workaround for running tests on Windows with Windows images.
|
|
100
|
+
* IRL, users should run linux images even on Windows.
|
|
101
|
+
*
|
|
102
|
+
* On Linux, we add the `:z` suffix for SELinux compatibility (required on Fedora, RHEL, etc.).
|
|
103
|
+
* The `:z` suffix tells Docker to relabel the volume content with a shared label.
|
|
104
|
+
* This is safe to use on non-SELinux systems as it's simply ignored.
|
|
105
|
+
*/
|
|
106
|
+
function getBindsLocation(image: string): string {
|
|
107
|
+
if (image.includes(':windows')) {
|
|
108
|
+
return `C:${sep}configs`;
|
|
109
|
+
}
|
|
110
|
+
// Add :z suffix for SELinux compatibility on Linux hosts
|
|
111
|
+
const selinuxSuffix = process.platform === 'linux' ? ':z' : '';
|
|
112
|
+
return `/configs${selinuxSuffix}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function checkDockerInstalled(): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
await _docker.version();
|
|
118
|
+
logger.info('Docker is installed and running.');
|
|
119
|
+
} catch (error: any) {
|
|
120
|
+
logger.error('Docker is not installed or running.');
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function pullDockerImage(image: string): Promise<void> {
|
|
126
|
+
logger.info(`Pulling docker image: ${image}`);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const stream = await _docker.pull(image);
|
|
130
|
+
await new Promise((resolve, reject) => {
|
|
131
|
+
_docker.modem.followProgress(stream, (err, res) => (err ? reject(err) : resolve(res)));
|
|
132
|
+
});
|
|
133
|
+
logger.info(`Docker image pulled: ${image}`);
|
|
134
|
+
} catch (error: any) {
|
|
135
|
+
logger.error(`Failed to pull docker image: ${image}`);
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function inspectDockerImage(image: string): Promise<any> {
|
|
141
|
+
logger.debug(`Inspecting docker image: ${image}`);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const imageInfo = await _docker.getImage(image).inspect();
|
|
145
|
+
logger.debug(`Docker image inspected: ${image}`);
|
|
146
|
+
|
|
147
|
+
return {digest: imageInfo.RepoDigests[0], version: imageInfo.Config.Labels['io.airbyte.version']};
|
|
148
|
+
} catch (error: any) {
|
|
149
|
+
logger.warn(`Failed to inspect docker image '${image}': ${error.message ?? JSON.stringify(error)}`);
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Process the destination output.
|
|
156
|
+
* Logs the messages and returns the state message if any.
|
|
157
|
+
*/
|
|
158
|
+
function processDstDataByLine(line: string, cfg: FarosConfig): AirbyteState | undefined {
|
|
159
|
+
if (line.trim() === '') {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const data = JSON.parse(line);
|
|
165
|
+
let state: AirbyteState | undefined;
|
|
166
|
+
|
|
167
|
+
if (data?.type === AirbyteMessageType.STATE && data?.state) {
|
|
168
|
+
state = extractStateFromMessage(data as AirbyteStateMessage);
|
|
169
|
+
logger.debug(formatDstMsg(data));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (cfg.rawMessages) {
|
|
173
|
+
process.stdout.write(`${line}\n`);
|
|
174
|
+
} else {
|
|
175
|
+
logDstMessage(data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return state;
|
|
179
|
+
} catch (error: any) {
|
|
180
|
+
logger.error(`Line of data: '${line}'; Error: ${error.message}`);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parses Airbyte output lines and extracts the message matching the specified type.
|
|
187
|
+
* Logs any error traces or error-level log messages encountered during parsing.
|
|
188
|
+
*/
|
|
189
|
+
function processAirbyteLines<T>(lines: string[], messageType: AirbyteMessageType): T | undefined {
|
|
190
|
+
let message: T | undefined;
|
|
191
|
+
lines.forEach((line: string) => {
|
|
192
|
+
try {
|
|
193
|
+
const parsedLine = JSON.parse(line);
|
|
194
|
+
if (line.includes(messageType)) {
|
|
195
|
+
message = parsedLine as T;
|
|
196
|
+
} else {
|
|
197
|
+
if (parsedLine?.trace?.type === 'ERROR' || parsedLine?.log?.level === 'ERROR') {
|
|
198
|
+
logger.error(line);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (error: any) {
|
|
202
|
+
logger.debug(`Failed to parse line: '${line}'; Error: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return message;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the docker container with the provided options and stdout stream.
|
|
210
|
+
* @param options - Docker container create options
|
|
211
|
+
* @param outputStream - Writable stream to capture the output
|
|
212
|
+
*/
|
|
213
|
+
export async function runDocker(
|
|
214
|
+
options: Docker.ContainerCreateOptions,
|
|
215
|
+
outputStream: Writable,
|
|
216
|
+
inputStream?: ReadStream | PassThrough,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
// Create the Docker container
|
|
219
|
+
const container = await _docker.createContainer(options);
|
|
220
|
+
|
|
221
|
+
// Attach the stderr to termincal stderr, and stdout to the output stream
|
|
222
|
+
const stdoutStream = await container.attach({stream: true, stdout: true, stderr: true});
|
|
223
|
+
container.modem.demuxStream(stdoutStream, outputStream, process.stderr);
|
|
224
|
+
|
|
225
|
+
// Attach the input stream to the container stdin
|
|
226
|
+
let stdinStream: NodeJS.ReadWriteStream | undefined;
|
|
227
|
+
if (inputStream) {
|
|
228
|
+
// Remove additional stdin data from the stdin stream
|
|
229
|
+
// Uderlying bug in dockerode:
|
|
230
|
+
// Workaround copied from issue: https://github.com/apocas/dockerode/issues/742
|
|
231
|
+
container.modem = new Proxy(container.modem, {
|
|
232
|
+
get(target, prop) {
|
|
233
|
+
const origMethod = target[prop];
|
|
234
|
+
// internally to send http requests to the docker daemon
|
|
235
|
+
if (prop === 'dial') {
|
|
236
|
+
return function (...args: any[]) {
|
|
237
|
+
if (args[0].path.endsWith('/attach?')) {
|
|
238
|
+
// send an empty json payload instead
|
|
239
|
+
args[0].file = Buffer.from('');
|
|
240
|
+
}
|
|
241
|
+
return origMethod.apply(target, args);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return origMethod;
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
stdinStream = await container.attach({stream: true, hijack: true, stdin: true});
|
|
249
|
+
inputStream.pipe(stdinStream);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Start the container
|
|
253
|
+
await container.start();
|
|
254
|
+
runningContainers.add(container.id);
|
|
255
|
+
|
|
256
|
+
// Wait for the container to finish
|
|
257
|
+
let res: {StatusCode: number};
|
|
258
|
+
try {
|
|
259
|
+
res = await container.wait();
|
|
260
|
+
} finally {
|
|
261
|
+
runningContainers.delete(container.id);
|
|
262
|
+
}
|
|
263
|
+
logger.debug(`Container exit code: ${JSON.stringify(res)}`);
|
|
264
|
+
|
|
265
|
+
// Close docker attached stream explicitly
|
|
266
|
+
// This is supposed to be automatically closed when the container exits
|
|
267
|
+
// but on Windows we intermittently experienced issues with the stream not being closed.
|
|
268
|
+
try {
|
|
269
|
+
if (stdoutStream) {
|
|
270
|
+
(stdoutStream as any).destroy();
|
|
271
|
+
}
|
|
272
|
+
if (stdinStream) {
|
|
273
|
+
(stdinStream as any).destroy();
|
|
274
|
+
}
|
|
275
|
+
} catch (error: any) {
|
|
276
|
+
logger.debug(`Failed to destroy streams: ${error.message} ?? ${JSON.stringify(error)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (res?.StatusCode !== 0) {
|
|
280
|
+
throw new Error(`Container exited with code ${res.StatusCode}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Spinning up a docker container to check the source connection.
|
|
286
|
+
* `docker run --rm -v "$tempdir:/configs" $src_docker_options "$src_docker_image"
|
|
287
|
+
* check --config "/configs/$src_config_filename"`
|
|
288
|
+
*
|
|
289
|
+
* Sample output from the docker container:
|
|
290
|
+
* {"connectionStatus":{"status":"SUCCEEDED"},"type":"CONNECTION_STATUS"}
|
|
291
|
+
* {"connectionStatus":{"status":"FAILED","message":"Faros API key was not provided"},"type":"CONNECTION_STATUS"}
|
|
292
|
+
*/
|
|
293
|
+
export async function runCheckSrcConnection(tmpDir: string, image: string, srcConfigFile?: string): Promise<void> {
|
|
294
|
+
logger.info('Validating connection to source...');
|
|
295
|
+
|
|
296
|
+
if (!image) {
|
|
297
|
+
throw new Error('Source image is missing.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const cfgFile = srcConfigFile ?? SRC_CONFIG_FILENAME;
|
|
302
|
+
const command = ['check', '--config', `/configs/${cfgFile}`];
|
|
303
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
304
|
+
Image: image,
|
|
305
|
+
Cmd: command,
|
|
306
|
+
AttachStderr: true,
|
|
307
|
+
AttachStdout: true,
|
|
308
|
+
HostConfig: {
|
|
309
|
+
Binds: [`${tmpDir}:${getBindsLocation(image)}`],
|
|
310
|
+
AutoRemove: true,
|
|
311
|
+
},
|
|
312
|
+
platform: getImagePlatform(image),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// create a writable stream to capture the output and process line by line
|
|
316
|
+
let buffer = '';
|
|
317
|
+
let status: AirbyteConnectionStatusMessage | undefined;
|
|
318
|
+
const containerOutputStream = new Writable({
|
|
319
|
+
write(chunk, _encoding, callback) {
|
|
320
|
+
buffer += chunk.toString();
|
|
321
|
+
const lines = buffer.split('\n');
|
|
322
|
+
buffer = lines.pop() ?? '';
|
|
323
|
+
const result = processAirbyteLines<AirbyteConnectionStatusMessage>(lines, AirbyteMessageType.CONNECTION_STATUS);
|
|
324
|
+
if (result !== undefined) {
|
|
325
|
+
status = result;
|
|
326
|
+
}
|
|
327
|
+
callback();
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// run docker
|
|
332
|
+
await docker.runDocker(createOptions, containerOutputStream);
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
status?.type === AirbyteMessageType.CONNECTION_STATUS &&
|
|
336
|
+
status?.connectionStatus.status === AirbyteConnectionStatus.SUCCEEDED
|
|
337
|
+
) {
|
|
338
|
+
logger.info('Source connection is valid.');
|
|
339
|
+
} else {
|
|
340
|
+
throw new Error(status?.connectionStatus.message);
|
|
341
|
+
}
|
|
342
|
+
} catch (error: any) {
|
|
343
|
+
throw new Error(`Failed to validate source connection: ${error.message ?? JSON.stringify(error)}.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get catalog configuration
|
|
349
|
+
*
|
|
350
|
+
* Docker cli command:
|
|
351
|
+
* docker run --rm -v "$tempdir:/configs" "$src_docker_image" discover \
|
|
352
|
+
* --config "/configs/$src_config_filename"
|
|
353
|
+
*/
|
|
354
|
+
export async function runDiscoverCatalog(tmpDir: string, image: string | undefined): Promise<AirbyteCatalog> {
|
|
355
|
+
logger.info('Discovering catalog...');
|
|
356
|
+
|
|
357
|
+
if (!image) {
|
|
358
|
+
throw new Error('Source image is missing.');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const command = ['discover', '--config', `/configs/${SRC_CONFIG_FILENAME}`];
|
|
363
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
364
|
+
Image: image,
|
|
365
|
+
Cmd: command,
|
|
366
|
+
AttachStderr: true,
|
|
367
|
+
AttachStdout: true,
|
|
368
|
+
HostConfig: {
|
|
369
|
+
Binds: [`${tmpDir}:${getBindsLocation(image)}`],
|
|
370
|
+
AutoRemove: true,
|
|
371
|
+
},
|
|
372
|
+
platform: getImagePlatform(image),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// create a writable stream to capture the output and process line by line
|
|
376
|
+
let buffer = '';
|
|
377
|
+
let rawCatalog: AirbyteCatalogMessage | undefined;
|
|
378
|
+
const containerOutputStream = new Writable({
|
|
379
|
+
write(chunk, _encoding, callback) {
|
|
380
|
+
buffer += chunk.toString();
|
|
381
|
+
const lines = buffer.split('\n');
|
|
382
|
+
buffer = lines.pop() ?? '';
|
|
383
|
+
const result = processAirbyteLines<AirbyteCatalogMessage>(lines, AirbyteMessageType.CATALOG);
|
|
384
|
+
if (result !== undefined) {
|
|
385
|
+
rawCatalog = result;
|
|
386
|
+
}
|
|
387
|
+
callback();
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// run docker
|
|
392
|
+
await docker.runDocker(createOptions, containerOutputStream);
|
|
393
|
+
|
|
394
|
+
if (rawCatalog?.type === AirbyteMessageType.CATALOG) {
|
|
395
|
+
logger.info('Catalog discovered successfully.');
|
|
396
|
+
return rawCatalog.catalog ?? {streams: []};
|
|
397
|
+
}
|
|
398
|
+
throw new Error('Catalog not found or container ends with non-zero status code');
|
|
399
|
+
} catch (error: any) {
|
|
400
|
+
throw new Error(`Failed to discover catalog: ${error.message ?? JSON.stringify(error)}.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Spinning up a docker container to run source airbyte connector.
|
|
406
|
+
* Platform is set to 'linux/amd64' as we only publish airbyte connectors images for linux/amd64.
|
|
407
|
+
*
|
|
408
|
+
* Docker cli command:
|
|
409
|
+
* docker run --name $src_container_name --init \
|
|
410
|
+
* -v "$tempdir:/configs" \
|
|
411
|
+
* $max_memory $max_cpus --log-opt max-size="$max_log_size" \
|
|
412
|
+
* --env LOG_LEVEL="$log_level" \
|
|
413
|
+
* $src_docker_options
|
|
414
|
+
* --cidfile="$tempPrefix-src_cid" \
|
|
415
|
+
* -a stdout -a stderr \
|
|
416
|
+
* "$src_docker_image" \
|
|
417
|
+
* read \
|
|
418
|
+
* --config "/configs/$src_config_filename" \
|
|
419
|
+
* --catalog "/configs/$src_catalog_filename" \
|
|
420
|
+
* --state "/configs/$src_state_filename"
|
|
421
|
+
*/
|
|
422
|
+
export async function runSrcSync(tmpDir: string, config: FarosConfig, srcOutputStream?: Writable): Promise<void> {
|
|
423
|
+
logger.info('Running source connector...');
|
|
424
|
+
|
|
425
|
+
if (!config.src?.image) {
|
|
426
|
+
throw new Error('Source image is missing.');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const timestamp = Date.now();
|
|
431
|
+
const srcContainerName = `airbyte-local-src-${timestamp}`;
|
|
432
|
+
const cmd = [
|
|
433
|
+
'read',
|
|
434
|
+
'--config',
|
|
435
|
+
`/configs/${SRC_CONFIG_FILENAME}`,
|
|
436
|
+
'--catalog',
|
|
437
|
+
`/configs/${SRC_CATALOG_FILENAME}`,
|
|
438
|
+
'--state',
|
|
439
|
+
`/configs/${DEFAULT_STATE_FILE}`,
|
|
440
|
+
];
|
|
441
|
+
// 1e9 nano cpus = 1 cpu
|
|
442
|
+
const maxNanoCpus = config.src?.dockerOptions?.maxCpus ? config.src?.dockerOptions?.maxCpus * 1e9 : undefined;
|
|
443
|
+
// 1024 * 1024 bytes = 1MB
|
|
444
|
+
const maxMemory = config.src?.dockerOptions?.maxMemory
|
|
445
|
+
? config.src?.dockerOptions?.maxMemory * 1024 * 1024
|
|
446
|
+
: undefined;
|
|
447
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
448
|
+
// Default config: can be overridden by the docker options provided by users
|
|
449
|
+
name: srcContainerName,
|
|
450
|
+
Image: config.src.image,
|
|
451
|
+
...config.src?.dockerOptions?.additionalOptions,
|
|
452
|
+
|
|
453
|
+
// Default options: cannot be overridden by users
|
|
454
|
+
Cmd: cmd,
|
|
455
|
+
AttachStdout: true,
|
|
456
|
+
AttachStderr: true,
|
|
457
|
+
platform: getImagePlatform(config.src.image),
|
|
458
|
+
Env: [`LOG_LEVEL=${config.logLevel}`, ...(config.src?.dockerOptions?.additionalOptions?.Env || [])],
|
|
459
|
+
HostConfig: {
|
|
460
|
+
// Defautl host config: can be overridden by users
|
|
461
|
+
NanoCpus: maxNanoCpus,
|
|
462
|
+
Memory: maxMemory,
|
|
463
|
+
LogConfig: {
|
|
464
|
+
Type: 'json-file',
|
|
465
|
+
Config: {
|
|
466
|
+
'max-size': config.src?.dockerOptions?.maxLogSize ?? DEFAULT_MAX_LOG_SIZE,
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
...config.src?.dockerOptions?.additionalOptions?.HostConfig,
|
|
470
|
+
|
|
471
|
+
// Allow users to add bind mount but not override the default one
|
|
472
|
+
Binds: [
|
|
473
|
+
`${tmpDir}:${getBindsLocation(config.src.image)}`,
|
|
474
|
+
...(config.src?.dockerOptions?.additionalOptions?.HostConfig?.Binds || []),
|
|
475
|
+
],
|
|
476
|
+
|
|
477
|
+
// Default options: cannot be overridden by users
|
|
478
|
+
AutoRemove: !config.keepContainers,
|
|
479
|
+
Init: true,
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// Create a writable stream for the processed output data
|
|
484
|
+
const outputStream =
|
|
485
|
+
srcOutputStream ??
|
|
486
|
+
(config.srcOutputFile && config.srcOutputFile !== OutputStream.STDOUT.valueOf()
|
|
487
|
+
? createWriteStream(config.srcOutputFile)
|
|
488
|
+
: process.stdout);
|
|
489
|
+
|
|
490
|
+
// create a writable stream to capture the stdout
|
|
491
|
+
let buffer = '';
|
|
492
|
+
const containerOutputStream = new Writable({
|
|
493
|
+
write(chunk, _encoding, callback) {
|
|
494
|
+
buffer += chunk.toString();
|
|
495
|
+
const lines = buffer.split('\n');
|
|
496
|
+
buffer = lines.pop() ?? '';
|
|
497
|
+
lines.forEach((line: string) => {
|
|
498
|
+
processSrcDataByLine(line, outputStream, config);
|
|
499
|
+
});
|
|
500
|
+
callback();
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// run docker
|
|
505
|
+
await docker.runDocker(createOptions, containerOutputStream);
|
|
506
|
+
|
|
507
|
+
// Close the container output stream
|
|
508
|
+
// This is required to notify the dst connector that inputs are done
|
|
509
|
+
containerOutputStream.end();
|
|
510
|
+
logger.debug('Container output stream ended.');
|
|
511
|
+
|
|
512
|
+
// Wait for the outputStream to finish writing
|
|
513
|
+
if (outputStream !== process.stdout) {
|
|
514
|
+
// close the output stream when the container output stream finishes writing
|
|
515
|
+
containerOutputStream.on('finish', () => {
|
|
516
|
+
outputStream.end();
|
|
517
|
+
logger.debug('Wrting file output stream closed.');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Wait for the outputStream to finish writing
|
|
521
|
+
await new Promise<void>((resolve, reject) => {
|
|
522
|
+
(outputStream as Writable).on('finish', resolve);
|
|
523
|
+
(outputStream as Writable).on('error', reject);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
logger.info('Source connector completed.');
|
|
528
|
+
} catch (error: any) {
|
|
529
|
+
throw new Error(`Failed to run source connector: ${error.message ?? JSON.stringify(error)}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Spinning up a docker container to run destination airbyte connector.
|
|
535
|
+
*
|
|
536
|
+
* Docker cli command:
|
|
537
|
+
* docker run \
|
|
538
|
+
* --name $dst_container_name \
|
|
539
|
+
* $dst_use_host_network \
|
|
540
|
+
* $max_memory $max_cpus \
|
|
541
|
+
* --cidfile="$tempPrefix-dst_cid" \
|
|
542
|
+
* -i --init \
|
|
543
|
+
* -v "$tempdir:/configs" \
|
|
544
|
+
* --log-opt max-size="$max_log_size" -a stdout -a stderr -a stdin \
|
|
545
|
+
* --env LOG_LEVEL="$log_level" \
|
|
546
|
+
* $dst_docker_options \
|
|
547
|
+
* "$dst_docker_image" \
|
|
548
|
+
* write \
|
|
549
|
+
* --config "/configs/$dst_config_filename" \
|
|
550
|
+
* --catalog "/configs/$dst_catalog_filename"
|
|
551
|
+
*
|
|
552
|
+
*/
|
|
553
|
+
export async function runDstSync(tmpDir: string, config: FarosConfig, srcPassThrough?: PassThrough): Promise<void> {
|
|
554
|
+
logger.info('Running destination connector...');
|
|
555
|
+
|
|
556
|
+
if (!config.dst?.image) {
|
|
557
|
+
throw new Error('Destination image is missing.');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const timestamp = Date.now();
|
|
562
|
+
const dstContainerName = `airbyte-local-dst-${timestamp}`;
|
|
563
|
+
const cmd = [
|
|
564
|
+
'write',
|
|
565
|
+
'--config',
|
|
566
|
+
`/configs/${DST_CONFIG_FILENAME}`,
|
|
567
|
+
'--catalog',
|
|
568
|
+
`/configs/${DST_CATALOG_FILENAME}`,
|
|
569
|
+
];
|
|
570
|
+
// 1e9 nano cpus = 1 cpu
|
|
571
|
+
const maxNanoCpus = config.src?.dockerOptions?.maxCpus ? config.src?.dockerOptions?.maxCpus * 1e9 : undefined;
|
|
572
|
+
// 1024 * 1024 bytes = 1MB
|
|
573
|
+
const maxMemory = config.src?.dockerOptions?.maxMemory
|
|
574
|
+
? config.src?.dockerOptions?.maxMemory * 1024 * 1024
|
|
575
|
+
: undefined;
|
|
576
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
577
|
+
// Default config: can be overridden by the docker options provided by users
|
|
578
|
+
name: dstContainerName,
|
|
579
|
+
Image: config.dst.image,
|
|
580
|
+
...config.dst?.dockerOptions?.additionalOptions,
|
|
581
|
+
|
|
582
|
+
// Default options: cannot be overridden by users
|
|
583
|
+
Cmd: cmd,
|
|
584
|
+
AttachStdout: true,
|
|
585
|
+
AttachStderr: true,
|
|
586
|
+
AttachStdin: true,
|
|
587
|
+
OpenStdin: true,
|
|
588
|
+
StdinOnce: true,
|
|
589
|
+
platform: getImagePlatform(config.dst.image),
|
|
590
|
+
Env: [
|
|
591
|
+
`LOG_LEVEL=${config.logLevel}`,
|
|
592
|
+
`CLI_USER_AGENT=${getUserAgent()}`,
|
|
593
|
+
...(config.dst?.dockerOptions?.additionalOptions?.Env || []),
|
|
594
|
+
],
|
|
595
|
+
HostConfig: {
|
|
596
|
+
// Defautl host config: can be overridden by users
|
|
597
|
+
NanoCpus: maxNanoCpus,
|
|
598
|
+
Memory: maxMemory,
|
|
599
|
+
LogConfig: {
|
|
600
|
+
Type: 'json-file',
|
|
601
|
+
Config: {
|
|
602
|
+
'max-size': config.dst?.dockerOptions?.maxLogSize ?? DEFAULT_MAX_LOG_SIZE,
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
...config.dst?.dockerOptions?.additionalOptions?.HostConfig,
|
|
606
|
+
|
|
607
|
+
// Allow users to add bind mount but not override the default one
|
|
608
|
+
Binds: [
|
|
609
|
+
`${tmpDir}:${getBindsLocation(config.dst.image)}`,
|
|
610
|
+
...(config.dst?.dockerOptions?.additionalOptions?.HostConfig?.Binds || []),
|
|
611
|
+
],
|
|
612
|
+
|
|
613
|
+
// Default options: cannot be overridden by users
|
|
614
|
+
AutoRemove: !config.keepContainers,
|
|
615
|
+
Init: true,
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// create a writable stream to capture the stdout
|
|
620
|
+
let buffer = '';
|
|
621
|
+
const streamStates = new Map<string, AirbyteState>();
|
|
622
|
+
const legacyState: {value: AirbyteState | undefined} = {value: undefined};
|
|
623
|
+
const containerOutputStream = new Writable({
|
|
624
|
+
write(chunk, _encoding, callback) {
|
|
625
|
+
buffer += chunk.toString();
|
|
626
|
+
const lines = buffer.split('\n');
|
|
627
|
+
buffer = lines.pop() ?? '';
|
|
628
|
+
lines.forEach((line: string) => {
|
|
629
|
+
const maybeState = processDstDataByLine(line, config);
|
|
630
|
+
collectStates(maybeState, streamStates, legacyState);
|
|
631
|
+
});
|
|
632
|
+
callback();
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Create a readable stream from the src output file and pipe it to the container stdin
|
|
637
|
+
const inputStream = srcPassThrough ?? createReadStream(`${tmpDir}/${SRC_OUTPUT_DATA_FILE}`);
|
|
638
|
+
|
|
639
|
+
// Start the container
|
|
640
|
+
await docker.runDocker(createOptions, containerOutputStream, inputStream);
|
|
641
|
+
logger.info('Destination connector completed.');
|
|
642
|
+
|
|
643
|
+
// Write the state file
|
|
644
|
+
writeStateFile(streamStates, legacyState, config.stateFile);
|
|
645
|
+
} catch (error: any) {
|
|
646
|
+
throw new Error(`Failed to run destination connector: ${error.message ?? JSON.stringify(error)}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Run the spec to generate the configuration file.
|
|
652
|
+
*
|
|
653
|
+
* TODO: Check if it's running fine on non faros airbyte images
|
|
654
|
+
*
|
|
655
|
+
* Raw docker command:
|
|
656
|
+
* docker run -it --rm "$docker_image" spec
|
|
657
|
+
*/
|
|
658
|
+
export async function runSpec(image: string): Promise<AirbyteSpec> {
|
|
659
|
+
logger.info('Retrieving Airbyte configuration spec...');
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
663
|
+
Image: image,
|
|
664
|
+
Cmd: ['spec'],
|
|
665
|
+
AttachStderr: true,
|
|
666
|
+
AttachStdout: true,
|
|
667
|
+
HostConfig: {
|
|
668
|
+
AutoRemove: true,
|
|
669
|
+
},
|
|
670
|
+
platform: getImagePlatform(image),
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// create a writable stream to capture the spec
|
|
674
|
+
let buffer = '';
|
|
675
|
+
let spec: AirbyteSpec | undefined;
|
|
676
|
+
const containerOutputStream = new Writable({
|
|
677
|
+
write(chunk, _encoding, callback) {
|
|
678
|
+
buffer += chunk.toString();
|
|
679
|
+
const lines = buffer.split('\n');
|
|
680
|
+
buffer = lines.pop() ?? '';
|
|
681
|
+
const result = processAirbyteLines<AirbyteSpec>(lines, AirbyteMessageType.SPEC);
|
|
682
|
+
if (result !== undefined) {
|
|
683
|
+
spec = result;
|
|
684
|
+
}
|
|
685
|
+
callback();
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// run docker
|
|
690
|
+
await docker.runDocker(createOptions, containerOutputStream);
|
|
691
|
+
|
|
692
|
+
if (spec?.type === AirbyteMessageType.SPEC) {
|
|
693
|
+
return spec;
|
|
694
|
+
}
|
|
695
|
+
throw new Error(`No spec found in the output`);
|
|
696
|
+
} catch (error: any) {
|
|
697
|
+
throw new Error(`Failed to run spec: ${error.message ?? JSON.stringify(error)}.`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Run the wizard to generate the configuration file.
|
|
703
|
+
* Utilize the `--autofill` flag to generate the configuration file.
|
|
704
|
+
*
|
|
705
|
+
* For feeds sources, use the actual source image with `--feed <feedName>`.
|
|
706
|
+
* For other sources, use the placeholder image.
|
|
707
|
+
*
|
|
708
|
+
* Raw docker command:
|
|
709
|
+
* docker run -it --rm \
|
|
710
|
+
* -v "$tempdir:/configs" "$docker_image" \
|
|
711
|
+
* airbyte-local-cli-wizard --autofill \
|
|
712
|
+
* --json "/configs/$config_filename"
|
|
713
|
+
* --spec-file "/configs/$spec_filename"
|
|
714
|
+
* [--feed <feedName>]
|
|
715
|
+
*/
|
|
716
|
+
const DEFAULT_PLACEHOLDER_WIZARD_IMAGE = 'farosai/airbyte-faros-graphql-source';
|
|
717
|
+
export async function runWizard(tmpDir: string, image: string, spec: AirbyteSpec, feedName?: string): Promise<any> {
|
|
718
|
+
logger.info('Retrieving Airbyte auto generated configuration...');
|
|
719
|
+
|
|
720
|
+
const wizardImage = feedName ? image : DEFAULT_PLACEHOLDER_WIZARD_IMAGE;
|
|
721
|
+
logger.info(`Pulling image to generate configuration...`);
|
|
722
|
+
await pullDockerImage(wizardImage);
|
|
723
|
+
|
|
724
|
+
// Write the spec to a file
|
|
725
|
+
writeFileSync(`${tmpDir}/${TMP_SPEC_CONFIG_FILENAME}`, JSON.stringify(spec));
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const cmd = [
|
|
729
|
+
'airbyte-local-cli-wizard',
|
|
730
|
+
'--autofill',
|
|
731
|
+
'--json',
|
|
732
|
+
`/configs/${TMP_WIZARD_CONFIG_FILENAME}`,
|
|
733
|
+
'--spec-file',
|
|
734
|
+
`/configs/${TMP_SPEC_CONFIG_FILENAME}`,
|
|
735
|
+
...(feedName ? ['--feed', feedName] : []),
|
|
736
|
+
];
|
|
737
|
+
const createOptions: Docker.ContainerCreateOptions = {
|
|
738
|
+
Image: wizardImage,
|
|
739
|
+
Cmd: cmd,
|
|
740
|
+
AttachStderr: true,
|
|
741
|
+
AttachStdout: true,
|
|
742
|
+
HostConfig: {
|
|
743
|
+
Binds: [`${tmpDir}:${getBindsLocation(image)}`],
|
|
744
|
+
AutoRemove: true,
|
|
745
|
+
},
|
|
746
|
+
platform: getImagePlatform(image),
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// run docker
|
|
750
|
+
await docker.runDocker(createOptions, process.stdout);
|
|
751
|
+
|
|
752
|
+
const resultConfig = JSON.parse(readFileSync(`${tmpDir}/${TMP_WIZARD_CONFIG_FILENAME}`, 'utf-8'));
|
|
753
|
+
return resultConfig;
|
|
754
|
+
} catch (error: any) {
|
|
755
|
+
throw new Error(`Failed to generate config: ${error.message ?? JSON.stringify(error)}.`);
|
|
756
|
+
}
|
|
757
|
+
}
|