1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const crypto = require("crypto");
|
4 | const _ = require("underscore");
|
5 | const os = require("os");
|
6 | const path = require("path");
|
7 | const request = require("request-promise");
|
8 | const docker_cli_js_1 = require("docker-cli-js");
|
9 | const cron_1 = require("cron");
|
10 | const uuid = require("uuid/v5");
|
11 | const sbase = require("@nodeswork/sbase");
|
12 | const logger = require("@nodeswork/logger");
|
13 | const utils_1 = require("@nodeswork/utils");
|
14 | const applet = require("@nodeswork/applet");
|
15 | const errors = require("./errors");
|
16 | const utils_2 = require("./utils");
|
17 | const server_1 = require("./server");
|
18 | const socket_1 = require("./socket");
|
19 | const paths_1 = require("./paths");
|
20 | const compareVersion = require("compare-version");
|
21 | const latestVersion = require('latest-version');
|
22 | const LOG = logger.getLogger();
|
23 | const APPLET_MANAGER_KEY = 'appletManager';
|
24 | const containerVersion = require('../package.json').version;
|
25 | const isRunning = require('is-running');
|
26 | const machineId = require('node-machine-id').machineIdSync;
|
27 | const UUID_NAMESPACE = '5daabcd8-f17e-568c-aa6f-da9d92c7032c';
|
28 | const NAME_REGEX = /^na-(\w+)-([0-9.]+)-([\w-]+)_([0-9.]+)-(\w+)$/;
|
29 | class AppletManager {
|
30 | constructor(options) {
|
31 | this.options = options;
|
32 | this.docker = new docker_cli_js_1.Docker();
|
33 | this.cronJobs = [];
|
34 | if (this.options.debug) {
|
35 | LOG.level = 'debug';
|
36 | }
|
37 | const configPath = path.join(options.appPath, 'config.json');
|
38 | LOG.debug('Load configuration from configPath', configPath);
|
39 | this.ls = utils_2.localStorage(configPath);
|
40 | let amOptions = this.ls.getItemSync(APPLET_MANAGER_KEY);
|
41 | let running = false;
|
42 | if (amOptions == null) {
|
43 | amOptions = this.options;
|
44 | LOG.debug('Initialize Applet Manager Options to local:', amOptions);
|
45 | this.ls.setItemSync(APPLET_MANAGER_KEY, amOptions);
|
46 | }
|
47 | else {
|
48 | LOG.debug('Got Applet Manager Options from local:', amOptions);
|
49 | if (amOptions.pid) {
|
50 | running = isRunning(amOptions.pid);
|
51 | }
|
52 | this.options.token = amOptions.token;
|
53 | }
|
54 | if (running && (this.options.appPath !== amOptions.appPath ||
|
55 | this.options.nodesworkServer !== amOptions.nodesworkServer ||
|
56 | this.options.port !== amOptions.port)) {
|
57 | throw new utils_1.NodesworkError('Configuration does not match with existing Applet Manager', {
|
58 | newOption: this.options,
|
59 | runningOption: _.pick(amOptions, 'appPath', 'nodesworkServer', 'port'),
|
60 | });
|
61 | }
|
62 | if (running) {
|
63 | this.options.pid = amOptions.pid;
|
64 | this.options.token = amOptions.token;
|
65 | }
|
66 | else {
|
67 | this.options.pid = null;
|
68 | }
|
69 | }
|
70 | authenticated() {
|
71 | return this.options.token != null;
|
72 | }
|
73 | |
74 |
|
75 |
|
76 |
|
77 |
|
78 | async authenticate(options) {
|
79 | try {
|
80 | const resp = await request.post({
|
81 | baseUrl: this.options.nodesworkServer,
|
82 | uri: '/v1/u/user/login',
|
83 | body: {
|
84 | email: options.email,
|
85 | password: options.password,
|
86 | },
|
87 | json: true,
|
88 | jar: true,
|
89 | });
|
90 | LOG.debug('Login successfully');
|
91 | }
|
92 | catch (e) {
|
93 | if (e.name === 'RequestError') {
|
94 | throw new utils_1.NodesworkError('Server is not available', {
|
95 | path: this.options.nodesworkServer + '/v1/u/user/login',
|
96 | }, e);
|
97 | }
|
98 | else if (e.statusCode === 401) {
|
99 | throw new utils_1.NodesworkError('Wrong password');
|
100 | }
|
101 | else if (e.statusCode === 422) {
|
102 | throw new utils_1.NodesworkError('Wrong email');
|
103 | }
|
104 | else {
|
105 | throw e;
|
106 | }
|
107 | }
|
108 | try {
|
109 | const mid = machineId();
|
110 | LOG.debug('Got machine id:', mid);
|
111 | const deviceIdentifier = crypto.createHash('md5')
|
112 | .update(mid)
|
113 | .update(options.email)
|
114 | .digest('hex');
|
115 | let operatingSystem = {
|
116 | Darwin: 'MacOS',
|
117 | Windows_NT: 'Windows',
|
118 | Linux: 'Linux',
|
119 | }[os.type()];
|
120 | const device = {
|
121 | deviceType: 'UserDevice',
|
122 | deviceIdentifier,
|
123 | os: operatingSystem,
|
124 | osVersion: os.release(),
|
125 | containerVersion,
|
126 | name: options.deviceName,
|
127 | };
|
128 | LOG.debug('Collected device information:', device);
|
129 | const resp = await request.post({
|
130 | baseUrl: this.options.nodesworkServer,
|
131 | uri: '/v1/u/devices',
|
132 | body: device,
|
133 | json: true,
|
134 | jar: true,
|
135 | });
|
136 | LOG.debug('Device registered successfully', resp);
|
137 | this.options.token = resp.token;
|
138 | this.ls.setItemSync(APPLET_MANAGER_KEY, this.options);
|
139 | LOG.debug('Save token to local', this.options);
|
140 | await this.updateDevice();
|
141 | }
|
142 | catch (e) {
|
143 | if (e.name === 'RequestError') {
|
144 | throw new utils_1.NodesworkError('Server is not available', {
|
145 | path: this.options.nodesworkServer + '/v1/u/user/login',
|
146 | }, e);
|
147 | }
|
148 | else {
|
149 | throw e;
|
150 | }
|
151 | }
|
152 | return;
|
153 | }
|
154 | isStarted() {
|
155 | return this.options.pid != null;
|
156 | }
|
157 | |
158 |
|
159 |
|
160 |
|
161 |
|
162 | async startServer() {
|
163 | if (this.options.pid != null) {
|
164 | console.log('daemon has already started');
|
165 | return;
|
166 | }
|
167 | if (this.options.token == null) {
|
168 | throw errors.UNAUTHENTICATED_ERROR;
|
169 | }
|
170 | await this.checkEnvironment();
|
171 |
|
172 | this.options.pid = process.pid;
|
173 | this.ls.setItemSync(APPLET_MANAGER_KEY, this.options);
|
174 | server_1.app.appletManager = this;
|
175 | server_1.server.listen(this.options.port);
|
176 | socket_1.connectSocket(this.options.nodesworkServer, this.options.token, this);
|
177 | LOG.info(`Server is started at http://localhost:${this.options.port}`);
|
178 | await this.updateDevice();
|
179 | }
|
180 | |
181 |
|
182 |
|
183 |
|
184 |
|
185 | async stopServer() {
|
186 |
|
187 | if (this.options.pid) {
|
188 | process.kill(this.options.pid);
|
189 | this.options.pid = null;
|
190 | await utils_2.sleep(1000);
|
191 | }
|
192 | }
|
193 | async install(options) {
|
194 | const docker = new docker_cli_js_1.Docker();
|
195 | const cmd = `build -t ${imageName(options)} --build-arg package=${options.packageName} --build-arg version=${options.version} docker/${options.naType}/${options.naVersion}`;
|
196 | LOG.debug('Execute command to install applet', { cmd });
|
197 | try {
|
198 | const result = await docker.command(cmd);
|
199 | LOG.debug('Execute build command log', result);
|
200 | await this.updateDevice();
|
201 | }
|
202 | catch (e) {
|
203 | throw e;
|
204 | }
|
205 | }
|
206 | async images() {
|
207 | const docker = new docker_cli_js_1.Docker();
|
208 | const images = await docker.command('images');
|
209 | return _.chain(images.images)
|
210 | .filter((image) => {
|
211 | const [na, naType, naVersion, ...others] = image.repository.split('-');
|
212 | return na === 'na' && (naType === 'npm') && (naVersion === '8.3.0');
|
213 | })
|
214 | .map((image) => {
|
215 | const [na, naType, naVersion, ...others] = image.repository.split('-');
|
216 | return {
|
217 | naType,
|
218 | naVersion,
|
219 | packageName: others.join('-'),
|
220 | version: image.tag,
|
221 | };
|
222 | })
|
223 | .value();
|
224 | }
|
225 | async run(options) {
|
226 | await this.checkEnvironment();
|
227 | const uniqueName = this.name(options);
|
228 | const rmCmd = `rm ${uniqueName}`;
|
229 | LOG.debug('Execute command to rm applet', { cmd: rmCmd });
|
230 | try {
|
231 | const docker = new docker_cli_js_1.Docker();
|
232 | const result = await docker.command(rmCmd);
|
233 | LOG.debug('Execute build command log', result);
|
234 | }
|
235 | catch (e) {
|
236 | LOG.debug('Container does not exist');
|
237 | }
|
238 | const image = imageName(options);
|
239 | const cmd = `run --name ${uniqueName} --network nodeswork -d -e ${applet.constants.environmentKeys.APPLET_ID}=${options.appletId} -e ${applet.constants.environmentKeys.APPLET_TOKEN}=${options.appletToken} ${image}`;
|
240 | LOG.debug('Execute command to run applet', { cmd });
|
241 | try {
|
242 | const docker = new docker_cli_js_1.Docker();
|
243 | const result = await docker.command(cmd);
|
244 | LOG.debug('Execute run command result', result);
|
245 | await this.updateDevice();
|
246 | }
|
247 | catch (e) {
|
248 | LOG.error('Execute run command failed', e);
|
249 | throw e;
|
250 | }
|
251 | }
|
252 | async kill(options) {
|
253 | const uniqueName = `na-npm-${options.packageName}_${options.version}`;
|
254 | const cmd = `stop ${uniqueName}`;
|
255 | LOG.debug('Execute command to run applet', { cmd });
|
256 | try {
|
257 | const docker = new docker_cli_js_1.Docker();
|
258 | const result = await docker.command(cmd);
|
259 | LOG.debug('Execute build command log', result);
|
260 | await this.updateDevice();
|
261 | }
|
262 | catch (e) {
|
263 | throw e;
|
264 | }
|
265 | }
|
266 | async ps() {
|
267 | await this.checkEnvironment();
|
268 | const psResult = await this.docker.command('ps');
|
269 | const psApplets = _.chain(psResult.containerList)
|
270 | .map((container) => {
|
271 | const image = parseAppletImage(container.names);
|
272 | if (image == null) {
|
273 | return null;
|
274 | }
|
275 | const port = parseMappingPort(container.ports) || 28900;
|
276 | return _.extend(image, { port, status: container.status });
|
277 | })
|
278 | .filter(_.identity)
|
279 | .value();
|
280 | const networkResults = Object.values((await this.docker.command('network inspect nodeswork')).object[0].Containers);
|
281 | return _.filter(psApplets, (psApplet) => {
|
282 | const appletName = this.name(psApplet);
|
283 | const networkResult = _.find(networkResults, (result) => {
|
284 | return result.Name === appletName;
|
285 | });
|
286 | if (networkResult == null) {
|
287 | LOG.warn(`Applet ${appletName} is running but not in the correct network`);
|
288 | return false;
|
289 | }
|
290 | psApplet.ip = networkResult.IPv4Address.split('/')[0];
|
291 | return true;
|
292 | });
|
293 | }
|
294 | async refreshWorkerCrons() {
|
295 | const self = this;
|
296 | try {
|
297 | const userApplets = await request.get({
|
298 | headers: {
|
299 | 'device-token': this.options.token,
|
300 | },
|
301 | baseUrl: this.options.nodesworkServer,
|
302 | uri: '/v1/d/user-applets',
|
303 | json: true,
|
304 | jar: true,
|
305 | });
|
306 | const newJobs = _
|
307 | .chain(userApplets)
|
308 | .map((ua) => {
|
309 | const appletConfig = ua.config.appletConfig;
|
310 | const image = {
|
311 | naType: appletConfig.naType,
|
312 | naVersion: appletConfig.naVersion,
|
313 | packageName: appletConfig.packageName,
|
314 | version: appletConfig.version,
|
315 | };
|
316 | return _.map(appletConfig.workers, (workerConfig) => {
|
317 | const worker = {
|
318 | handler: workerConfig.handler,
|
319 | name: workerConfig.name,
|
320 | };
|
321 | return {
|
322 | jobUUID: uuid([
|
323 | ua.applet._id,
|
324 | ua._id,
|
325 | image.naType,
|
326 | image.naVersion,
|
327 | image.packageName,
|
328 | image.version,
|
329 | worker.handler,
|
330 | worker.name,
|
331 | ].join(':'), UUID_NAMESPACE),
|
332 | appletId: ua.applet._id,
|
333 | userApplet: ua._id,
|
334 | image,
|
335 | worker,
|
336 | schedule: workerConfig.schedule,
|
337 | };
|
338 | });
|
339 | })
|
340 | .flatten()
|
341 | .filter((x) => x.schedule != null)
|
342 | .value();
|
343 | LOG.debug('Fetch applets for current device successfully');
|
344 | for (const cron of this.cronJobs) {
|
345 | const u = _.find(newJobs, (newJob) => newJob.jobUUID === cron.jobUUID);
|
346 | if (u == null) {
|
347 | cron.cronJob.stop();
|
348 | LOG.info('Stop cron job successfully', _.omit(cron, 'cronJob'));
|
349 | }
|
350 | }
|
351 | for (const newJob of newJobs) {
|
352 | const cron = _.find(this.cronJobs, (c) => newJob.jobUUID === c.jobUUID);
|
353 | if (cron == null) {
|
354 | const cronJob = (function (c) {
|
355 | try {
|
356 | c.cronJob = new cron_1.CronJob({
|
357 | cronTime: c.schedule,
|
358 | onTick: async () => {
|
359 | LOG.debug('Run cron job', _.omit(c, 'cronJob'));
|
360 | try {
|
361 | await self.executeCronJob(c);
|
362 | LOG.info('Run cron job successfully', _.omit(c, 'cronJob'));
|
363 | }
|
364 | catch (e) {
|
365 | LOG.error('Run cron job failed', e, _.omit(c, 'cronJob'));
|
366 | }
|
367 | },
|
368 | start: true,
|
369 | });
|
370 | }
|
371 | catch (e) {
|
372 | LOG.error('Create cron job failed', _.omit(c, 'cronJob'));
|
373 | }
|
374 | LOG.info('Create cron job successfully', _.omit(c, 'cronJob'));
|
375 | return c;
|
376 | })(newJob);
|
377 | this.cronJobs.push(cronJob);
|
378 | }
|
379 | }
|
380 | }
|
381 | catch (e) {
|
382 | throw e;
|
383 | }
|
384 | }
|
385 | async executeCronJob(job) {
|
386 | try {
|
387 | const accounts = await request.get({
|
388 | headers: {
|
389 | 'device-token': this.options.token,
|
390 | },
|
391 | baseUrl: this.options.nodesworkServer,
|
392 | uri: `/v1/d/user-applets/${job.userApplet}/accounts`,
|
393 | json: true,
|
394 | jar: true,
|
395 | });
|
396 | LOG.debug('Fetch accounts successfully', accounts);
|
397 | const payload = {
|
398 | accounts,
|
399 | };
|
400 | const result = await this.work({
|
401 | route: {
|
402 | appletId: job.appletId,
|
403 | naType: job.image.naType,
|
404 | naVersion: job.image.naVersion,
|
405 | packageName: job.image.packageName,
|
406 | version: job.image.version,
|
407 | },
|
408 | worker: job.worker,
|
409 | payload,
|
410 | });
|
411 | LOG.info('Execute cron job successfully.', {
|
412 | job: _.omit(job, 'cronJob'),
|
413 | result,
|
414 | });
|
415 | }
|
416 | catch (e) {
|
417 | throw e;
|
418 | }
|
419 | }
|
420 | async work(options) {
|
421 | LOG.debug('Get work request', options);
|
422 | const requestOptions = {
|
423 | appletId: options.route.appletId,
|
424 | naType: options.route.naType,
|
425 | naVersion: options.route.naVersion,
|
426 | packageName: options.route.packageName,
|
427 | version: options.route.version,
|
428 | uri: `/workers/${options.worker.handler}/${options.worker.name}`,
|
429 | method: 'POST',
|
430 | body: options.payload,
|
431 | };
|
432 | return await this.request(requestOptions);
|
433 | }
|
434 | async request(options) {
|
435 | LOG.info('Get request', { options });
|
436 | const routeAddress = await this.route(options);
|
437 | if (routeAddress == null) {
|
438 | throw new utils_1.NodesworkError('Applet is not running');
|
439 | }
|
440 | const headers = _.extend({}, options.headers);
|
441 | headers[sbase.constants.headers.request.NODESWORK_FORWARDED_TO] = (routeAddress.route);
|
442 | const requestOptions = {
|
443 | uri: routeAddress.target + options.uri,
|
444 | method: options.method,
|
445 | proxy: routeAddress.target,
|
446 | body: options.body,
|
447 | headers,
|
448 | json: true,
|
449 | };
|
450 | LOG.debug('Request options', requestOptions);
|
451 | const resp = await request(requestOptions);
|
452 | LOG.debug('Request response', resp);
|
453 | return resp;
|
454 | }
|
455 | async operateAccount(options) {
|
456 | const requestOptions = {
|
457 | uri: `/v1/d/applets/${options.appletId}/accounts/${options.accountId}/operate`,
|
458 | baseUrl: this.options.nodesworkServer,
|
459 | body: options.body,
|
460 | headers: {
|
461 | 'device-token': this.options.token,
|
462 | },
|
463 | json: true,
|
464 | jar: true,
|
465 | };
|
466 | return await request.post(requestOptions);
|
467 | }
|
468 | async route(options) {
|
469 | if (this.options.dev) {
|
470 | try {
|
471 | const devServer = await request({
|
472 | uri: 'http://localhost:28900/sstats',
|
473 | json: true,
|
474 | });
|
475 | if (devServer.applet &&
|
476 | devServer.applet.packageName === options.packageName &&
|
477 | compareVersion(devServer.applet.packageVersion, options.version) >= 0) {
|
478 | return {
|
479 | route: 'localhost:28900',
|
480 | target: 'http://localhost:28900',
|
481 | };
|
482 | }
|
483 | }
|
484 | catch (e) {
|
485 |
|
486 | }
|
487 | }
|
488 | return {
|
489 | route: `${this.name(options)}:28900`,
|
490 | target: paths_1.containerProxyUrl,
|
491 | };
|
492 | }
|
493 | async updateDevice() {
|
494 | if (this.options.token == null) {
|
495 | throw errors.UNAUTHENTICATED_ERROR;
|
496 | }
|
497 | const installedApplets = await this.images();
|
498 | const runningApplets = await this.ps();
|
499 | try {
|
500 | const resp = await request.post({
|
501 | headers: {
|
502 | 'device-token': this.options.token,
|
503 | },
|
504 | baseUrl: this.options.nodesworkServer,
|
505 | uri: '/v1/d/devices',
|
506 | body: {
|
507 | installedApplets,
|
508 | runningApplets,
|
509 | },
|
510 | json: true,
|
511 | jar: true,
|
512 | });
|
513 | LOG.debug('Update device successfully');
|
514 | }
|
515 | catch (e) {
|
516 | throw e;
|
517 | }
|
518 | await this.refreshWorkerCrons();
|
519 | }
|
520 | async checkEnvironment() {
|
521 |
|
522 | const networks = await this.docker.command('network ls');
|
523 | const targetNetwork = _.find(networks.network, (c) => c.name === 'nodeswork');
|
524 | if (targetNetwork == null) {
|
525 | LOG.debug('network is not setup, creating');
|
526 | await this.docker.command('network create nodeswork');
|
527 | }
|
528 | const inspect = await this.docker.command('network inspect nodeswork');
|
529 | LOG.debug('inspecting network', inspect.object[0]);
|
530 | const IPAMConfig = inspect.object[0].IPAM.Config[0];
|
531 | this.network = {
|
532 | subnet: IPAMConfig.Subnet,
|
533 | gateway: IPAMConfig.Gateway,
|
534 | containers: inspect.object[0].Containers,
|
535 | };
|
536 | LOG.debug('Network configuration', this.network);
|
537 |
|
538 |
|
539 | const containers = await this.docker.command('ps');
|
540 | const proxyContainer = _.find(containers.containerList, (container) => container.names === 'nodeswork-container-proxy');
|
541 | if (proxyContainer == null) {
|
542 | LOG.debug('Container proxy is not running, starting');
|
543 | await this.installContainerProxy();
|
544 | }
|
545 | else {
|
546 | const version = proxyContainer.image.split(':')[1];
|
547 | const lVersion = await latestVersion('@nodeswork/container-proxy');
|
548 | this.containerProxy = {
|
549 | version,
|
550 | latestVersion: lVersion,
|
551 | };
|
552 | }
|
553 | const proxyInNetwork = _.find(this.network.containers, (container) => {
|
554 | return container.Name === 'nodeswork-container-proxy';
|
555 | });
|
556 | if (proxyInNetwork == null) {
|
557 | LOG.debug('Proxy container is not in network');
|
558 | await this.docker.command(`network connect nodeswork nodeswork-container-proxy`);
|
559 | }
|
560 | LOG.debug('Container Proxy configuration', this.containerProxy);
|
561 | LOG.debug('Environment setup correctly');
|
562 | }
|
563 | name(options) {
|
564 | return `na-${options.naType}-${options.naVersion}-${options.packageName}_${options.version}-${options.appletId}`;
|
565 | }
|
566 | async installContainerProxy() {
|
567 | const version = await latestVersion('@nodeswork/container-proxy');
|
568 | LOG.debug('Fetched latest version container-proxy', { version });
|
569 | const output = await this.docker.command(`build -t nodeswork-container-proxy:${version} docker/container-proxy --build-arg version=${version}`);
|
570 | LOG.debug('Building container proxy', output);
|
571 | try {
|
572 | await this.docker.command(`rm nodeswork-container-proxy`);
|
573 | }
|
574 | catch (e) {
|
575 | LOG.debug('Remove container proxy error', e);
|
576 | }
|
577 | try {
|
578 |
|
579 | await this.docker.command(`run --name nodeswork-container-proxy -d -e NAM_HOST=172.16.222.111:28310 -e SUB_NET=${this.network.subnet} -p 28320:28320 nodeswork-container-proxy:${version}`);
|
580 | }
|
581 | catch (e) {
|
582 | LOG.debug('Remove container proxy error', e);
|
583 | }
|
584 | this.containerProxy = { version, latestVersion: version };
|
585 | }
|
586 | }
|
587 | exports.AppletManager = AppletManager;
|
588 | function imageName(image) {
|
589 | return `na-${image.naType}-${image.naVersion}-${image.packageName}\
|
590 | :${image.version}`;
|
591 | }
|
592 | function parseAppletImage(name) {
|
593 | const result = NAME_REGEX.exec(name);
|
594 | if (result == null) {
|
595 | return null;
|
596 | }
|
597 | return {
|
598 | naType: result[1],
|
599 | naVersion: result[2],
|
600 | packageName: result[3],
|
601 | version: result[4],
|
602 | appletId: result[5],
|
603 | };
|
604 | }
|
605 | function parseMappingPort(ports) {
|
606 | return parseInt(ports.split(':')[1]);
|
607 | }
|
608 |
|
609 |
|