🌟 Support of OpenSearch as a search database (#304)

- Added common search adapter for ES and OpenSearch
-  Open Search integration tests add to the build workflow
 - Stop docker-compose environment before running the next stop
This commit is contained in:
Anton Shepilov
2024-01-04 12:59:05 +03:00
committed by GitHub
parent 6f7f714fe8
commit 927d5a62bb
8 changed files with 79 additions and 377 deletions
+6
View File
@@ -21,6 +21,12 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: e2e-mongo-test - name: e2e-mongo-test
run: cd tdrive && docker-compose -f docker-compose.tests.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=mongodb -e DB_DRIVER=mongodb -e PUBSUB_TYPE=local node npm run test:all run: cd tdrive && docker-compose -f docker-compose.tests.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=mongodb -e DB_DRIVER=mongodb -e PUBSUB_TYPE=local node npm run test:all
- name: e2e-mongo-test-clean
run: cd tdrive && docker-compose -f docker-compose.tests.yml stop
- name: e2e-opensearch-test
run: cd tdrive && docker-compose -f docker-compose.dev.tests.opensearch.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=opensearch -e DB_DRIVER=mongodb -e PUBSUB_TYPE=local node npm run test:all
- name: e2e-opensearch-test-clean
run: cd tdrive && docker-compose -f docker-compose.dev.tests.opensearch.yml stop
- name: e2e-cassandra-test - name: e2e-cassandra-test
run: cd tdrive && docker-compose -f docker-compose.tests.yml up -d scylladb elasticsearch rabbitmq && sleep 60 && docker-compose -f docker-compose.tests.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=elasticsearch -e DB_DRIVER=cassandra node npm run test:all run: cd tdrive && docker-compose -f docker-compose.tests.yml up -d scylladb elasticsearch rabbitmq && sleep 60 && docker-compose -f docker-compose.tests.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=elasticsearch -e DB_DRIVER=cassandra node npm run test:all
- name: coverage - name: coverage
@@ -1,23 +1,23 @@
import { Client } from "@elastic/elasticsearch";
import { logger } from "../../../../framework"; import { logger } from "../../../../framework";
import _ from "lodash"; import _ from "lodash";
import { import {
ColumnDefinition, ColumnDefinition,
EntityDefinition, EntityDefinition,
EntityTarget, EntityTarget,
ESSearchConfiguration,
FindFilter, FindFilter,
FindOptions, FindOptions,
IndexedEntity, IndexedEntity,
SearchAdapterInterface, SearchAdapterInterface,
SearchConfiguration,
} from "../../api"; } from "../../api";
import { SearchAdapter } from "../abstract"; import { SearchAdapter } from "../abstract";
import { DatabaseServiceAPI } from "../../../database/api"; import { DatabaseServiceAPI } from "../../../database/api";
import { getEntityDefinition, unwrapPrimarykey } from "../../api"; import { getEntityDefinition, unwrapPrimarykey } from "../../api";
import { ListResult, Paginable, Pagination } from "../../../../framework/api/crud-service"; import { ListResult, Paginable, Pagination } from "../../../../framework/api/crud-service";
import { asciiFold, parsePrimaryKey, stringifyPrimaryKey } from "../utils"; import { asciiFold, parsePrimaryKey, stringifyPrimaryKey } from "../utils";
import { ApiResponse } from "@elastic/elasticsearch/lib/Transport";
import { buildSearchQuery } from "./search"; import { buildSearchQuery } from "./search";
import { Client as OpenClient } from "@opensearch-project/opensearch";
import { Client as ESClient } from "@elastic/elasticsearch";
type Operation = { type Operation = {
index?: { _index: string; _id: string }; index?: { _index: string; _id: string };
@@ -25,15 +25,16 @@ type Operation = {
[key: string]: any; [key: string]: any;
}; };
export default class ElasticSearch extends SearchAdapter implements SearchAdapterInterface { export default class ESAndOpenSearch extends SearchAdapter implements SearchAdapterInterface {
private client: Client;
private bulkReaders = 0; private bulkReaders = 0;
private buffer: Operation[] = []; private buffer: Operation[] = [];
private name = "ElasticSearch"; private client: OpenClient | ESClient;
constructor( constructor(
readonly database: DatabaseServiceAPI, readonly database: DatabaseServiceAPI,
readonly configuration: SearchConfiguration["elasticsearch"], readonly configuration: ESSearchConfiguration,
readonly newClient: (arg: any) => OpenClient | ESClient,
readonly name,
) { ) {
super(); super();
} }
@@ -55,10 +56,10 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
}; };
} }
this.client = new Client(clientOptions); this.client = this.newClient(clientOptions);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Unable to connect to ElasticSearch for options: ${JSON.stringify({ `Unable to connect to ${this.name} for options: ${JSON.stringify({
node: this.configuration.endpoint, node: this.configuration.endpoint,
auth: { auth: {
useAuth: this.configuration.useAuth, useAuth: this.configuration.useAuth,
@@ -86,6 +87,8 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
const mapping = entity.options?.search?.esMapping; const mapping = entity.options?.search?.esMapping;
try { try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await this.client.indices.get({ await this.client.indices.get({
index: name, index: name,
}); });
@@ -109,7 +112,8 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
mappings: { ...mapping, _source: { enabled: false } }, mappings: { ...mapping, _source: { enabled: false } },
}, },
}; };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const rep = await this.client.indices.create(indice, { ignore: [400] }); const rep = await this.client.indices.create(indice, { ignore: [400] });
if (rep.statusCode !== 200) { if (rep.statusCode !== 200) {
@@ -165,7 +169,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
...body, ...body,
}; };
logger.info(`Add operation upsert to elasticsearch for doc ${record.id}`); logger.info(`Add operation upsert to ${this.name} for doc ${record.id}`);
this.buffer.push(record); this.buffer.push(record);
} }
@@ -192,7 +196,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
}, },
}; };
logger.info(`Add operation remove from elasticsearch for doc ${record.id}`); logger.info(`Add operation remove from ${this.name} for doc ${record.id}`);
this.buffer.push(record); this.buffer.push(record);
} }
@@ -205,7 +209,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
return; return;
} }
logger.info("Start new Elasticsearch bulk reader."); logger.info(`Start new ${this.name} bulk reader.`);
this.bulkReaders += 1; this.bulkReaders += 1;
let buffer; let buffer;
@@ -224,7 +228,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
onDocument: (doc: Operation) => { onDocument: (doc: Operation) => {
if (doc.delete) { if (doc.delete) {
logger.info( logger.info(
`Operation ${"DELETE"} pushed to elasticsearch index ${doc.delete._index} (doc.id: ${ `Operation ${"DELETE"} pushed to ${this.name} index ${doc.delete._index} (doc.id: ${
doc.delete._id doc.delete._id
})`, })`,
); );
@@ -234,7 +238,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
} }
if (doc.index) { if (doc.index) {
logger.info( logger.info(
`Operation ${"INDEX"} pushed to elasticsearch index ${doc.index._index} (doc.id: ${ `Operation ${"INDEX"} pushed to ${this.name} index ${doc.index._index} (doc.id: ${
doc.index._id doc.index._id
})`, })`,
); );
@@ -248,11 +252,9 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
onDrop: res => { onDrop: res => {
const doc = res.document; const doc = res.document;
logger.error( logger.error(
`Operation ${ `Operation ${doc.action} was droped while pushing to ${
doc.action this.name
} was droped while pushing to elasticsearch index ${JSON.stringify( } index ${JSON.stringify(doc.index)} (doc.id: ${doc.id})`,
doc.index,
)} (doc.id: ${doc.id})`,
); );
logger.error(res.error); logger.error(res.error);
}, },
@@ -262,7 +264,7 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
logger.error(err); logger.error(err);
} }
logger.info("Elasticsearch bulk flushed."); logger.info(`${this.name} bulk flushed.`);
this.bulkReaders += -1; this.bulkReaders += -1;
this.startBulkReader(); this.startBulkReader();
@@ -284,9 +286,11 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
scroll: "1m", scroll: "1m",
}; };
let esResponse: ApiResponse; let esResponse: any;
if (options.pagination.page_token) { if (options.pagination.page_token) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
esResponse = await this.client.scroll( esResponse = await this.client.scroll(
{ {
scroll_id: options.pagination.page_token, scroll_id: options.pagination.page_token,
@@ -294,6 +298,8 @@ export default class ElasticSearch extends SearchAdapter implements SearchAdapte
esOptions, esOptions,
); );
} else { } else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
esResponse = await this.client.search(esParamsWithScroll, esOptions); esResponse = await this.client.search(esParamsWithScroll, esOptions);
} }
@@ -1,328 +0,0 @@
import { logger } from "../../../../framework";
import _ from "lodash";
import {
ColumnDefinition,
EntityDefinition,
EntityTarget,
FindFilter,
FindOptions,
IndexedEntity,
SearchAdapterInterface,
SearchConfiguration,
} from "../../api";
import { SearchAdapter } from "../abstract";
import { DatabaseServiceAPI } from "../../../database/api";
import { getEntityDefinition, unwrapPrimarykey } from "../../api";
import { ListResult, Paginable, Pagination } from "../../../../framework/api/crud-service";
import { asciiFold, parsePrimaryKey, stringifyPrimaryKey } from "../utils";
import { buildSearchQuery } from "../elasticsearch/search";
import { Client } from "@opensearch-project/opensearch";
type Operation = {
index?: { _index: string; _id: string };
delete?: { _index: string; _id: string };
[key: string]: any;
};
export default class OpenSearch extends SearchAdapter implements SearchAdapterInterface {
private client: Client;
private bulkReaders = 0;
private buffer: Operation[] = [];
private name = "ElasticSearch";
constructor(
readonly database: DatabaseServiceAPI,
readonly configuration: SearchConfiguration["elasticsearch"],
) {
super();
}
public async connect() {
try {
const clientOptions: any = {
node: this.configuration.endpoint,
ssl: {
rejectUnauthorized: false,
},
};
if (this.configuration.useAuth) {
logger.info("Using auth for ES client");
clientOptions.auth = {
username: this.configuration.username,
password: this.configuration.password,
};
}
this.client = new Client(clientOptions);
} catch (e) {
logger.error(
`Unable to connect to ElasticSearch for options: ${JSON.stringify({
node: this.configuration.endpoint,
auth: {
useAuth: this.configuration.useAuth,
username: this.configuration.username,
password: this.configuration.password,
},
ssl: {
rejectUnauthorized: false,
},
})} at: ${this.configuration.endpoint}`,
);
}
this.startBulkReader();
}
private async createIndex(
entity: EntityDefinition,
_columns: { [name: string]: ColumnDefinition },
) {
if (!entity.options?.search) {
return;
}
const name = entity.options?.search?.index || entity.name;
const mapping = entity.options?.search?.esMapping;
try {
await this.client.indices.get({
index: name,
});
logger.info(`Index "${name}" already created`);
} catch (e) {
logger.info(`Create index ${name} with mapping %o`, mapping);
const indice = {
index: name,
body: {
settings: {
analysis: {
analyzer: {
folding: {
tokenizer: "standard",
filter: ["lowercase", "asciifolding"],
},
},
},
},
mappings: { ...mapping, _source: { enabled: false } },
},
};
const rep = await this.client.indices.create(indice, { ignore: [400] });
if (rep.statusCode !== 200) {
logger.error(`${this.name} - ${JSON.stringify(rep.body)}`);
}
}
}
public async upsert(entities: any[]) {
for (const entity of entities) {
const { entityDefinition, columnsDefinition } = getEntityDefinition(entity);
const pkColumns = unwrapPrimarykey(entityDefinition);
await this.ensureIndex(entityDefinition, columnsDefinition, this.createIndex.bind(this));
if (!entityDefinition.options?.search) {
return;
}
if (
entityDefinition.options.search.shouldUpdate &&
!entityDefinition.options.search.shouldUpdate(entity)
) {
return;
}
if (!entityDefinition.options?.search?.source) {
logger.info(`Unable to do operation upsert to elasticsearch for doc ${entity}`);
return;
}
const body = {
..._.pick(entity, ...pkColumns),
...entityDefinition.options.search.source(entity),
};
Object.keys(entityDefinition.options?.search.esMapping?.properties || []).forEach(
(key: string) => {
const mapping: any = entityDefinition.options?.search?.esMapping?.properties[key];
if (mapping.type === "text") {
body[key] = asciiFold(body[key]).toLocaleLowerCase();
}
},
);
const index = entityDefinition.options?.search?.index || entityDefinition.name;
const record: Operation = {
index: {
_index: index,
_id: stringifyPrimaryKey(entity),
},
...body,
};
logger.info(`Add operation upsert to elasticsearch for doc ${record.id}`);
this.buffer.push(record);
}
this.startBulkReader();
}
public async remove(entities: any[]) {
for (const entity of entities) {
const { entityDefinition, columnsDefinition } = getEntityDefinition(entity);
await this.ensureIndex(entityDefinition, columnsDefinition, this.createIndex.bind(this));
if (!entityDefinition.options?.search) {
return;
}
const index = entityDefinition.options?.search?.index || entityDefinition.name;
const record: Operation = {
delete: {
_index: index,
_id: stringifyPrimaryKey(entity),
},
};
logger.info(`Add operation remove from elasticsearch for doc ${record.id}`);
this.buffer.push(record);
}
this.startBulkReader();
}
private async startBulkReader() {
if (this.bulkReaders > 0) {
return;
}
logger.info("Start new Elasticsearch bulk reader.");
this.bulkReaders += 1;
let buffer;
do {
await new Promise(r =>
setTimeout(r, parseInt(`${this.configuration.flushInterval}`) || 3000),
);
buffer = this.buffer;
} while (buffer.length === 0);
this.buffer = [];
try {
await this.client.helpers.bulk({
flushInterval: 1,
datasource: buffer,
onDocument: (doc: Operation) => {
if (doc.delete) {
logger.info(
`Operation ${"DELETE"} pushed to elasticsearch index ${doc.delete._index} (doc.id: ${
doc.delete._id
})`,
);
return {
delete: doc.delete,
};
}
if (doc.index) {
logger.info(
`Operation ${"INDEX"} pushed to elasticsearch index ${doc.index._index} (doc.id: ${
doc.index._id
})`,
);
return {
index: doc.index,
...doc.index,
};
}
return null;
},
onDrop: res => {
const doc = res.document;
logger.error(
`Operation ${
doc.action
} was droped while pushing to elasticsearch index ${JSON.stringify(
doc.index,
)} (doc.id: ${doc.id})`,
);
logger.error(res.error);
},
});
} catch (err) {
logger.error(`${this.name} - An error occured with the bulk reader`);
logger.error(err);
}
logger.info("Elasticsearch bulk flushed.");
this.bulkReaders += -1;
this.startBulkReader();
}
public async search<EntityType>(
_table: string,
entityType: EntityTarget<EntityType>,
filters: FindFilter,
options: FindOptions = {},
) {
const instance = new (entityType as any)();
const { entityDefinition } = getEntityDefinition(instance);
const { esParams, esOptions } = buildSearchQuery<EntityType>(entityType, filters, options);
const esParamsWithScroll = {
...esParams,
size: parseInt(options.pagination.limitStr || "100"),
scroll: "1m",
};
let esResponse: any;
if (options.pagination.page_token) {
esResponse = await this.client.scroll(
{
scroll_id: options.pagination.page_token,
},
esOptions,
);
} else {
esResponse = await this.client.search(esParamsWithScroll, esOptions);
}
if (esResponse.statusCode !== 200) {
logger.error(`${this.name} - ${JSON.stringify(esResponse.body)}`);
}
const nextToken = esResponse.body?._scroll_id || "";
const hits = esResponse.body?.hits?.hits || [];
logger.debug(`${this.name} got response: ${JSON.stringify(esResponse)}`);
const entities: IndexedEntity[] = [];
for await (const hit of hits) {
try {
entities.push({
primaryKey: parsePrimaryKey(entityDefinition, hit._id),
score: hit._score,
});
} catch (err) {
logger.error(
`${this.name} failed to get entity from search result: ${JSON.stringify(
hit._id,
)}, ${JSON.stringify(err)}`,
);
}
}
const nextPage: Paginable = new Pagination(nextToken, options.pagination.limitStr || "100");
return new ListResult(entityDefinition.type, entities, nextPage);
}
}
@@ -65,12 +65,15 @@ export interface SearchServiceAPI extends TdriveServiceProvider {
} }
export type SearchConfiguration = { export type SearchConfiguration = {
type?: false | "elasticsearch" | "mongodb"; type?: false | "elasticsearch" | "mongodb" | "opensearch";
elasticsearch?: { elasticsearch?: ESSearchConfiguration;
useAuth: boolean; opensearch?: ESSearchConfiguration;
username?: string; };
password?: string;
endpoint: string; export type ESSearchConfiguration = {
flushInterval: number; //In milliseconds useAuth: boolean;
}; username?: string;
password?: string;
endpoint: string;
flushInterval: number; //In milliseconds
}; };
@@ -3,15 +3,18 @@ import {
DatabaseEntitiesRemovedEvent, DatabaseEntitiesRemovedEvent,
DatabaseEntitiesSavedEvent, DatabaseEntitiesSavedEvent,
EntityTarget, EntityTarget,
ESSearchConfiguration,
SearchAdapterInterface, SearchAdapterInterface,
SearchConfiguration, SearchConfiguration,
SearchServiceAPI, SearchServiceAPI,
} from "./api"; } from "./api";
import ElasticsearchService from "./adapters/elasticsearch"; import ESAndOpenSearch from "./adapters/elasticsearch/elastic-open-search-adapter";
import MongosearchService from "./adapters/mongosearch"; import MongosearchService from "./adapters/mongosearch";
import { localEventBus } from "../../framework/event-bus"; import { localEventBus } from "../../framework/event-bus";
import { DatabaseServiceAPI } from "../database/api"; import { DatabaseServiceAPI } from "../database/api";
import SearchRepository from "./repository"; import SearchRepository from "./repository";
import { Client as OpenClient } from "@opensearch-project/opensearch";
import { Client as ESClient } from "@elastic/elasticsearch";
@ServiceName("search") @ServiceName("search")
@Consumes(["database"]) @Consumes(["database"])
@@ -29,9 +32,19 @@ export default class Search extends TdriveService<SearchServiceAPI> {
if (type === "elasticsearch") { if (type === "elasticsearch") {
logger.info("Loaded Elasticsearch adapter for search."); logger.info("Loaded Elasticsearch adapter for search.");
this.service = new ElasticsearchService( this.service = new ESAndOpenSearch(
this.database, this.database,
this.configuration.get("elasticsearch") as SearchConfiguration["elasticsearch"], this.configuration.get("elasticsearch") as ESSearchConfiguration,
config => new ESClient(config),
"ElasticSearch",
);
} else if (type === "opensearch") {
logger.info("Loaded OpenSearch adapter for search.");
this.service = new ESAndOpenSearch(
this.database,
this.configuration.get("opensearch") as ESSearchConfiguration,
config => new OpenClient(config),
"OpenSearch",
); );
} else if (type === "mongodb") { } else if (type === "mongodb") {
logger.info("Loaded Mongo adapter for search."); logger.info("Loaded Mongo adapter for search.");
@@ -41,7 +54,7 @@ export default class Search extends TdriveService<SearchServiceAPI> {
this.service = null; this.service = null;
} }
if (this.service) this.service.connect(); if (this.service) await this.service.connect();
return this; return this;
} }
@@ -60,11 +73,11 @@ export default class Search extends TdriveService<SearchServiceAPI> {
return this; return this;
} }
public async upsert(entities: any[]) { public async upsert(entities: never[]) {
return this.service.upsert(entities); return this.service.upsert(entities);
} }
public async remove(entities: any[]) { public async remove(entities: never[]) {
return this.service.remove(entities); return this.service.remove(entities);
} }
@@ -45,7 +45,7 @@ export default class LocalConnectorService implements StorageConnectorAPI {
async read(path: string): Promise<Readable> { async read(path: string): Promise<Readable> {
const fullPath = this.getFullPath(path); const fullPath = this.getFullPath(path);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
throw new Error("File doesn't exists"); throw new Error(`File doesn't exists ${fullPath}`);
} }
return createReadStream(fullPath); return createReadStream(fullPath);
} }
+13 -10
View File
@@ -25,8 +25,8 @@ services:
# - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container # - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container
ports: ports:
- 9200:9200 # REST API - 9200:9200 # REST API
- 9600:9600 # Performance Analyzer # - 9600:9600 # Performance Analyzer
# networks: # networks:
# - opensearch-net # All of the containers will join the same Docker bridge network # - opensearch-net # All of the containers will join the same Docker bridge network
mongo: mongo:
@@ -39,22 +39,25 @@ services:
node: node:
image: tdrive/tdrive-node:test image: tdrive/tdrive-node:test
container_name: node-test
build: build:
context: . context: .
dockerfile: docker/tdrive-node/Dockerfile dockerfile: docker/tdrive-node/Dockerfile
target: test target: development
volumes:
- ./coverage/:/usr/src/app/coverage/
environment: environment:
- LOG_LEVEL=error
- NODE_ENV=test - NODE_ENV=test
- DB_DRIVER - DB_DRIVER
#- PUBSUB_URLS=amqp://guest:guest@rabbitmq:5672 - PUBSUB_URLS=amqp://guest:guest@rabbitmq:5672
- SEARCH_ES_ENDPOINT=http://elasticsearch:9200 - SEARCH_OS_FLUSHINTERVAL=1
- SEARCH_ES_FLUSHINTERVAL=1 - SEARCH_OS_ENDPOINT=https://opensearch-node1:9200
volumes: - SEARCH_OS_PASSWORD=admin
- ./backend/node:/usr/src/app - SEARCH_OS_USE_AUTH=true
- ./docker-data/documents/:/storage/ - SEARCH_OS_USERNAME=admin
depends_on: depends_on:
- mongo - mongo
- opensearch-node1 - opensearch-node1
links: links:
- mongo - mongo
- opensearch-node1
+1 -2
View File
@@ -11,9 +11,7 @@ RUN apt-get update && \
# RUN apt-get update && apt-get install -y libc6 # RUN apt-get update && apt-get install -y libc6
# RUN ln -s /lib/libc.musl-x86_64.so.1 /lib/ld-linux-x86-64.so.2 # RUN ln -s /lib/libc.musl-x86_64.so.1 /lib/ld-linux-x86-64.so.2
### Install TDrive ### Install TDrive
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY backend/node/package*.json ./ COPY backend/node/package*.json ./
@@ -30,6 +28,7 @@ COPY backend/node/ .
#Install dev dependancies for build #Install dev dependancies for build
RUN export NODE_ENV=development RUN export NODE_ENV=development
RUN npm install RUN npm install
#Build in production mode #Build in production mode
RUN export NODE_ENV=production RUN export NODE_ENV=production
RUN npm run build RUN npm run build