@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.
Files changed (83) hide show
  1. package/DEVELOPER.md +76 -0
  2. package/LICENSE +201 -0
  3. package/README.md +440 -0
  4. package/lib/command.d.ts +11 -0
  5. package/lib/command.js +261 -0
  6. package/lib/command.js.map +1 -0
  7. package/lib/constants/airbyteConfig.d.ts +16 -0
  8. package/lib/constants/airbyteConfig.js +85 -0
  9. package/lib/constants/airbyteConfig.js.map +1 -0
  10. package/lib/constants/airbyteTypes.d.ts +14 -0
  11. package/lib/constants/airbyteTypes.js +214 -0
  12. package/lib/constants/airbyteTypes.js.map +1 -0
  13. package/lib/constants/constants.d.ts +11 -0
  14. package/lib/constants/constants.js +16 -0
  15. package/lib/constants/constants.js.map +1 -0
  16. package/lib/docker.d.ts +84 -0
  17. package/lib/docker.js +705 -0
  18. package/lib/docker.js.map +1 -0
  19. package/lib/index.d.ts +1 -0
  20. package/lib/index.js +89 -0
  21. package/lib/index.js.map +1 -0
  22. package/lib/logger.d.ts +2 -0
  23. package/lib/logger.js +11 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/types.d.ts +178 -0
  26. package/lib/types.js +63 -0
  27. package/lib/types.js.map +1 -0
  28. package/lib/utils.d.ts +105 -0
  29. package/lib/utils.js +732 -0
  30. package/lib/utils.js.map +1 -0
  31. package/lib/version.d.ts +1 -0
  32. package/lib/version.js +5 -0
  33. package/lib/version.js.map +1 -0
  34. package/package.json +84 -0
  35. package/scripts/lint +37 -0
  36. package/src/command.ts +304 -0
  37. package/src/constants/airbyteConfig.ts +96 -0
  38. package/src/constants/airbyteTypes.ts +226 -0
  39. package/src/constants/constants.ts +12 -0
  40. package/src/docker.ts +757 -0
  41. package/src/index.ts +109 -0
  42. package/src/logger.ts +5 -0
  43. package/src/tsconfig.json +6 -0
  44. package/src/types.ts +204 -0
  45. package/src/utils.ts +827 -0
  46. package/src/version.ts +1 -0
  47. package/test/__snapshots__/docker.it.test.ts.snap +422 -0
  48. package/test/__snapshots__/utils.it.test.ts.snap +365 -0
  49. package/test/command.test.ts +315 -0
  50. package/test/docker.it.test.ts +330 -0
  51. package/test/docker.test.ts +119 -0
  52. package/test/exec/.shellspec +13 -0
  53. package/test/exec/spec/index_spec.sh +441 -0
  54. package/test/exec/spec/spec_helper.sh +24 -0
  55. package/test/index.test.ts +158 -0
  56. package/test/pester/index.Tests.ps1 +315 -0
  57. package/test/resources/databricks_spec.json +145 -0
  58. package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_dst_catalog.json +16 -0
  59. package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_dst_config.json.template +9 -0
  60. package/test/resources/dockerIt_runDstSync/faros_airbyte_cli_src_output +51 -0
  61. package/test/resources/dockerIt_runSrcSync/faros_airbyte_cli_src_catalog.json +1 -0
  62. package/test/resources/dockerIt_runSrcSync/faros_airbyte_cli_src_config.json +3 -0
  63. package/test/resources/dockerIt_runSrcSync/state.json +1 -0
  64. package/test/resources/faros_airbyte_cli_src_config.json +8 -0
  65. package/test/resources/faros_airbyte_cli_src_config_chris.json +3 -0
  66. package/test/resources/faros_airbyte_cli_src_config_jennie.json +3 -0
  67. package/test/resources/graphql_spec.json +103 -0
  68. package/test/resources/test__state.json +1 -0
  69. package/test/resources/test__state_utf16.json +0 -0
  70. package/test/resources/test_config_file.json +38 -0
  71. package/test/resources/test_config_file_crlf.json +38 -0
  72. package/test/resources/test_config_file_dst_only.json.template +25 -0
  73. package/test/resources/test_config_file_graph_copy.json.template +25 -0
  74. package/test/resources/test_config_file_invalid +5 -0
  75. package/test/resources/test_config_file_src_only space.json +8 -0
  76. package/test/resources/test_config_file_src_only.json +8 -0
  77. package/test/resources/test_config_file_utf16.json +0 -0
  78. package/test/resources/test_src_input +9 -0
  79. package/test/resources/test_src_input_invalid_json +1 -0
  80. package/test/tsconfig.json +12 -0
  81. package/test/utils.it.test.ts +605 -0
  82. package/test/utils.test.ts +448 -0
  83. 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
+ }