From 40a21cfc165cfbd1db25ee1ffe0933acac144fbd Mon Sep 17 00:00:00 2001 From: burgerni10 <nicolas.burger@optimistik.com> Date: Wed, 13 Mar 2024 13:07:26 +0100 Subject: [PATCH] feat(logger): Add OIAnalytics Pino transport and refactor registration service to reload logger on change --- backend/package-lock.json | 237 ++++- backend/package.json | 3 +- .../v3.2.0-oia-registration.ts | 15 +- backend/src/index.ts | 33 +- .../src/repository/engine.repository.spec.ts | 38 +- backend/src/repository/engine.repository.ts | 29 +- backend/src/repository/log.repository.spec.ts | 22 +- .../src/service/logger/logger.service.spec.ts | 116 ++- backend/src/service/logger/logger.service.ts | 44 +- backend/src/service/logger/loki-transport.ts | 220 ----- .../service/logger/oianalytics-transport.ts | 156 ++++ .../service/{ => oia}/command.service.spec.ts | 27 +- .../src/service/{ => oia}/command.service.ts | 17 +- .../service/oia/registration.service.spec.ts | 865 ++++++++++++++++++ .../src/service/oia/registration.service.ts | 347 +++++++ backend/src/service/oibus.service.spec.ts | 763 +-------------- backend/src/service/oibus.service.ts | 324 +------ backend/src/service/reload.service.spec.ts | 13 +- backend/src/service/reload.service.ts | 23 +- .../src/tests/__mocks__/koa-context.mock.ts | 2 + .../src/tests/__mocks__/oibus-service.mock.ts | 13 +- .../__mocks__/registration-service.mock.ts | 7 + .../tests/__mocks__/reload-service.mock.ts | 1 + .../registration.controller.spec.ts | 14 +- .../controllers/registration.controller.ts | 8 +- .../validators/engine.validator.spec.ts | 24 +- .../validators/oibus-validation-schema.ts | 4 +- backend/src/web-server/koa.ts | 2 + backend/src/web-server/middlewares/oibus.ts | 3 + backend/src/web-server/web-server.ts | 3 + backend/tsconfig.app.json | 2 +- .../edit-engine/edit-engine.component.html | 22 +- .../edit-engine/edit-engine.component.spec.ts | 23 +- .../edit-engine/edit-engine.component.ts | 8 +- .../app/engine/engine-detail.component.html | 31 +- .../engine/engine-detail.component.spec.ts | 16 +- frontend/src/i18n/en.json | 15 +- shared/model/engine.model.ts | 5 +- shared/model/logs.model.ts | 3 +- 39 files changed, 1996 insertions(+), 1502 deletions(-) delete mode 100644 backend/src/service/logger/loki-transport.ts create mode 100644 backend/src/service/logger/oianalytics-transport.ts rename backend/src/service/{ => oia}/command.service.spec.ts (88%) rename backend/src/service/{ => oia}/command.service.ts (91%) create mode 100644 backend/src/service/oia/registration.service.spec.ts create mode 100644 backend/src/service/oia/registration.service.ts create mode 100644 backend/src/tests/__mocks__/registration-service.mock.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 578b6ad07b..b8851d872d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -51,6 +51,7 @@ "pg": "8.11.3", "pino": "8.19.0", "pino-abstract-transport": "1.1.0", + "pino-loki": "2.2.1", "pino-pretty": "10.3.1", "pino-roll": "1.0.0-rc.1", "selfsigned": "2.4.1", @@ -3231,6 +3232,17 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -3896,6 +3908,17 @@ "proper-lockfile": "^4.1.2" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -4009,6 +4032,17 @@ "@types/node": "*" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4093,6 +4127,11 @@ "integrity": "sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==", "dev": true }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -4185,6 +4224,14 @@ "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", "dev": true }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/koa": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", @@ -4372,6 +4419,14 @@ "safe-buffer": "~5.1.1" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -5538,6 +5593,45 @@ "node": ">= 6.0.0" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -5705,6 +5799,25 @@ "node": ">=12" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6105,6 +6218,14 @@ "node": ">=0.10.0" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -7819,6 +7940,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -8004,6 +8149,11 @@ "node": ">= 0.6" } }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -8044,6 +8194,18 @@ "node": ">= 14" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", @@ -9426,8 +9588,7 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9533,7 +9694,6 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -9865,6 +10025,14 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11331,6 +11499,17 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -11598,6 +11777,14 @@ "node": ">=14.6" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, "node_modules/p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -11976,6 +12163,23 @@ "split2": "^4.0.0" } }, + "node_modules/pino-loki": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pino-loki/-/pino-loki-2.2.1.tgz", + "integrity": "sha512-NLo9INo4lOQ8PfC0i/AZBh8xh6LCCkuGRuREq69Mw25zmoISlZiYCn5FBidagu+Cjm/dvhvt19THRhc0B71NnA==", + "dependencies": { + "commander": "^10.0.1", + "got": "^11.8.6", + "pino-abstract-transport": "^1.1.0", + "pump": "^3.0.0" + }, + "bin": { + "pino-loki": "dist/cli.cjs" + }, + "funding": { + "url": "https://github.com/sponsors/Julien-R44" + } + }, "node_modules/pino-pretty": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", @@ -12584,6 +12788,17 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -12752,6 +12967,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -12852,6 +13072,17 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", diff --git a/backend/package.json b/backend/package.json index a264040418..779103a410 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,6 +89,7 @@ "pg": "8.11.3", "pino": "8.19.0", "pino-abstract-transport": "1.1.0", + "pino-loki": "2.2.1", "pino-pretty": "10.3.1", "pino-roll": "1.0.0-rc.1", "selfsigned": "2.4.1", @@ -152,7 +153,7 @@ "assets": [ "dist/frontend/**/*", "dist/backend/src/service/logger/sqlite-transport.js", - "dist/backend/src/service/logger/loki-transport.js", + "dist/backend/src/service/logger/oianalytics-transport.js", "dist/backend/src/db/**/*.js", "dist/shared/**/*.js", "node_modules/sqlite3/**/*.node", diff --git a/backend/src/db/entity-migrations/v3.2.0-oia-registration.ts b/backend/src/db/entity-migrations/v3.2.0-oia-registration.ts index f4ec45d690..02f539b5de 100644 --- a/backend/src/db/entity-migrations/v3.2.0-oia-registration.ts +++ b/backend/src/db/entity-migrations/v3.2.0-oia-registration.ts @@ -88,11 +88,10 @@ interface NewSouthOIAnalyticsSettings { export async function up(knex: Knex): Promise<void> { await createRegistrationTable(knex); await createCommandTable(knex); - await addProxyServerSettings(knex); + await updateEngineSettings(knex); await updateNorthOIAnalyticsConnectors(knex); await updateSouthOIAnalyticsConnectors(knex); await updateOIAnalyticsHistoryQueries(knex); - await updateEngineLogSettings(knex); } function createDefaultEntityFields(table: CreateTableBuilder): void { @@ -100,10 +99,13 @@ function createDefaultEntityFields(table: CreateTableBuilder): void { table.timestamps(false, true); } -async function addProxyServerSettings(knex: Knex) { +async function updateEngineSettings(knex: Knex) { await knex.schema.alterTable(ENGINES_TABLE, table => { table.boolean('proxy_enabled').defaultTo(false); table.integer('proxy_port').defaultTo(9000); + table.enum('log_oia_level', LOG_LEVELS).notNullable().defaultTo('silent'); + table.integer('log_oia_interval').notNullable().defaultTo(10); + table.dropColumn('log_loki_token_address'); }); } @@ -267,13 +269,6 @@ async function updateOIAnalyticsHistoryQueries(knex: Knex): Promise<void> { } } -async function updateEngineLogSettings(knex: Knex): Promise<void> { - await knex.schema.table(ENGINES_TABLE, table => { - table.enum('log_oia_level', LOG_LEVELS).notNullable().defaultTo('silent'); - // TODO: simplify pino-loki - }); -} - export async function down(knex: Knex): Promise<void> { await knex.schema.dropTable(REGISTRATIONS_TABLE); await knex.schema.dropTable(COMMANDS_TABLE); diff --git a/backend/src/index.ts b/backend/src/index.ts index 64c8829028..e04a4078bc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,7 +15,8 @@ import HistoryQueryService from './service/history-query.service'; import OIBusService from './service/oibus.service'; import { migrateCrypto, migrateEntities, migrateLogsAndMetrics, migrateSouthCache } from './db/migration-service'; import HomeMetricsService from './service/home-metrics.service'; -import CommandService from './service/command.service'; +import CommandService from './service/oia/command.service'; +import RegistrationService from './service/oia/registration.service'; import ProxyServer from './web-server/proxy-server'; const CONFIG_DATABASE = 'oibus.db'; @@ -77,7 +78,12 @@ const LOG_DB_NAME = 'logs.db'; await createFolder(LOG_FOLDER_NAME); const loggerService = new LoggerService(encryptionService, path.resolve(LOG_FOLDER_NAME)); - await loggerService.start(oibusSettings.id, oibusSettings.name, oibusSettings.logParameters); + await loggerService.start( + oibusSettings.id, + oibusSettings.name, + oibusSettings.logParameters, + repositoryService.registrationRepository.getRegistrationSettings() + ); const northService = new NorthService(encryptionService, repositoryService); const southService = new SouthService(encryptionService, repositoryService); @@ -101,17 +107,10 @@ const LOG_DB_NAME = 'logs.db'; loggerService.logger! ); - const commandService = new CommandService(oibusSettings.id, repositoryService, encryptionService, loggerService.logger!, binaryFolder); + const commandService = new CommandService(repositoryService, encryptionService, loggerService.logger!, binaryFolder); commandService.start(); - const oibusService = new OIBusService( - engine, - historyQueryEngine, - repositoryService, - encryptionService, - commandService, - loggerService.logger! - ); + const oibusService = new OIBusService(engine, historyQueryEngine); await engine.start(); await historyQueryEngine.start(); @@ -138,13 +137,24 @@ const LOG_DB_NAME = 'logs.db'; southService, engine, historyQueryEngine, + oibusService, proxyServer ); + + const registrationService = new RegistrationService( + repositoryService, + encryptionService, + commandService, + reloadService, + loggerService.logger! + ); + registrationService.start(); const server = new WebServer( oibusSettings.id, oibusSettings.port, encryptionService, reloadService, + registrationService, repositoryService, southService, northService, @@ -164,6 +174,7 @@ const LOG_DB_NAME = 'logs.db'; await commandService.stop(); await proxyServer.stop(); await server.stop(); + registrationService.stop(); loggerService.stop(); console.info('OIBus stopped'); stopping = false; diff --git a/backend/src/repository/engine.repository.spec.ts b/backend/src/repository/engine.repository.spec.ts index b7a1f52dcd..4f7b7da230 100644 --- a/backend/src/repository/engine.repository.spec.ts +++ b/backend/src/repository/engine.repository.spec.ts @@ -47,12 +47,12 @@ describe('Empty engine repository', () => { level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' }, oia: { - level: 'silent' + level: 'silent', + interval: 10 } } }; @@ -62,7 +62,7 @@ describe('Empty engine repository', () => { expect(database.prepare).toHaveBeenCalledWith( 'INSERT INTO engines (id, name, port, proxy_enabled, proxy_port, log_console_level, log_file_level, log_file_max_file_size, ' + 'log_file_number_of_files, log_database_level, log_database_max_number_of_logs, log_loki_level, ' + - 'log_loki_interval, log_loki_address, log_loki_token_address, log_loki_username, log_loki_password, log_oia_level) ' + + 'log_loki_interval, log_loki_address, log_loki_username, log_loki_password, log_oia_level, log_oia_interval) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);' ); expect(run).toHaveBeenCalledWith( @@ -80,10 +80,10 @@ describe('Empty engine repository', () => { command.logParameters.loki.level, command.logParameters.loki.interval, command.logParameters.loki.address, - command.logParameters.loki.tokenAddress, command.logParameters.loki.username, command.logParameters.loki.password, - command.logParameters.oia.level + command.logParameters.oia.level, + command.logParameters.oia.interval ); expect(run).toHaveBeenCalledTimes(2); @@ -112,12 +112,12 @@ describe('Empty engine repository', () => { level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' }, oia: { - level: 'silent' + level: 'silent', + interval: 10 } } }; @@ -125,8 +125,8 @@ describe('Empty engine repository', () => { expect(database.prepare).toHaveBeenCalledWith( 'UPDATE engines SET name = ?, port = ?, proxy_enabled = ?, proxy_port = ?, log_console_level = ?, log_file_level = ?, log_file_max_file_size = ?, ' + 'log_file_number_of_files = ?, log_database_level = ?, log_database_max_number_of_logs = ?, log_loki_level = ?, ' + - 'log_loki_interval = ?, log_loki_address = ?, log_loki_token_address = ?, log_loki_username = ?, ' + - 'log_loki_password = ?, log_oia_level = ? WHERE rowid=(SELECT MIN(rowid) FROM engines);' + 'log_loki_interval = ?, log_loki_address = ?, log_loki_username = ?, ' + + 'log_loki_password = ?, log_oia_level = ?, log_oia_interval = ? WHERE rowid=(SELECT MIN(rowid) FROM engines);' ); expect(run).toHaveBeenCalledWith( command.name, @@ -142,10 +142,10 @@ describe('Empty engine repository', () => { command.logParameters.loki.level, command.logParameters.loki.interval, command.logParameters.loki.address, - command.logParameters.loki.tokenAddress, command.logParameters.loki.username, command.logParameters.loki.password, - command.logParameters.oia.level + command.logParameters.oia.level, + command.logParameters.oia.interval ); }); }); @@ -166,10 +166,10 @@ describe('Non-empty Engine repository', () => { lokiLogLevel: 'silent', lokiLogInterval: 60, lokiLogAddress: '', - lokiLogTokenAddress: '', lokiLogUsername: '', lokiLogPassword: '', - oiaLogLevel: 'silent' + oiaLogLevel: 'silent', + oiaLogInterval: 10 }; beforeEach(() => { jest.clearAllMocks(); @@ -207,12 +207,12 @@ describe('Non-empty Engine repository', () => { level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' }, oia: { - level: 'silent' + level: 'silent', + interval: 10 } } }; @@ -222,8 +222,8 @@ describe('Non-empty Engine repository', () => { 'log_file_max_file_size AS fileLogMaxFileSize, log_file_number_of_files AS fileLogNumberOfFiles, ' + 'log_database_level AS databaseLogLevel, log_database_max_number_of_logs AS databaseLogMaxNumberOfLogs, ' + 'log_loki_level AS lokiLogLevel, log_loki_interval AS lokiLogInterval, log_loki_address AS lokiLogAddress, ' + - 'log_loki_token_address AS lokiLogTokenAddress, log_loki_username AS lokiLogUsername, ' + - 'log_loki_password AS lokiLogPassword, log_oia_level AS oiaLogLevel FROM engines;' + 'log_loki_username AS lokiLogUsername, log_loki_password AS lokiLogPassword, ' + + 'log_oia_level AS oiaLogLevel, log_oia_interval AS oiaLogInterval FROM engines;' ); expect(all).toHaveBeenCalledTimes(2); expect(engineSettings).toEqual(expectedValue); @@ -252,12 +252,12 @@ describe('Non-empty Engine repository', () => { level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' }, oia: { - level: 'silent' + level: 'silent', + interval: 10 } } }; diff --git a/backend/src/repository/engine.repository.ts b/backend/src/repository/engine.repository.ts index 8df43c0b37..a114fdd35c 100644 --- a/backend/src/repository/engine.repository.ts +++ b/backend/src/repository/engine.repository.ts @@ -26,12 +26,12 @@ const defaultEngineSettings: EngineSettingsCommandDTO = { level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' }, oia: { - level: 'silent' + level: 'silent', + interval: 10 } } }; @@ -63,10 +63,10 @@ export default class EngineRepository { 'log_loki_level AS lokiLogLevel, ' + 'log_loki_interval AS lokiLogInterval, ' + 'log_loki_address AS lokiLogAddress, ' + - 'log_loki_token_address AS lokiLogTokenAddress, ' + 'log_loki_username AS lokiLogUsername, ' + 'log_loki_password AS lokiLogPassword, ' + - 'log_oia_level AS oiaLogLevel ' + + 'log_oia_level AS oiaLogLevel, ' + + 'log_oia_interval AS oiaLogInterval ' + `FROM ${ENGINES_TABLE};`; const results: Array<any> = this.database.prepare(query).all(); @@ -94,12 +94,12 @@ export default class EngineRepository { level: results[0].lokiLogLevel, interval: results[0].lokiLogInterval, address: results[0].lokiLogAddress, - tokenAddress: results[0].lokiLogTokenAddress, username: results[0].lokiLogUsername, password: results[0].lokiLogPassword }, oia: { - level: results[0].oiaLogLevel + level: results[0].oiaLogLevel, + interval: results[0].oiaLogInterval } } }; @@ -123,10 +123,10 @@ export default class EngineRepository { 'log_loki_level = ?, ' + 'log_loki_interval = ?, ' + 'log_loki_address = ?, ' + - 'log_loki_token_address = ?, ' + 'log_loki_username = ?, ' + 'log_loki_password = ?, ' + - 'log_oia_level = ? ' + + 'log_oia_level = ?, ' + + 'log_oia_interval = ? ' + `WHERE rowid=(SELECT MIN(rowid) FROM ${ENGINES_TABLE});`; this.database @@ -145,10 +145,10 @@ export default class EngineRepository { command.logParameters.loki.level, command.logParameters.loki.interval, command.logParameters.loki.address, - command.logParameters.loki.tokenAddress, command.logParameters.loki.username, command.logParameters.loki.password, - command.logParameters.oia.level + command.logParameters.oia.level, + command.logParameters.oia.interval ); } @@ -163,8 +163,9 @@ export default class EngineRepository { const query = `INSERT INTO ${ENGINES_TABLE} (id, name, port, proxy_enabled, proxy_port, log_console_level, ` + 'log_file_level, log_file_max_file_size, log_file_number_of_files, log_database_level, ' + - 'log_database_max_number_of_logs, log_loki_level, log_loki_interval, log_loki_address, log_loki_token_address, ' + - 'log_loki_username, log_loki_password, log_oia_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);'; + 'log_database_max_number_of_logs, log_loki_level, log_loki_interval, log_loki_address, ' + + 'log_loki_username, log_loki_password, log_oia_level, log_oia_interval) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);'; this.database .prepare(query) .run( @@ -182,10 +183,10 @@ export default class EngineRepository { command.logParameters.loki.level, command.logParameters.loki.interval, command.logParameters.loki.address, - command.logParameters.loki.tokenAddress, command.logParameters.loki.username, command.logParameters.loki.password, - command.logParameters.oia.level + command.logParameters.oia.level, + command.logParameters.oia.interval ); } } diff --git a/backend/src/repository/log.repository.spec.ts b/backend/src/repository/log.repository.spec.ts index e36729ee8c..03bae4c29c 100644 --- a/backend/src/repository/log.repository.spec.ts +++ b/backend/src/repository/log.repository.spec.ts @@ -78,21 +78,35 @@ describe('Log repository', () => { it('should add logs', () => { repository.addLogs([ - { msg: 'my message 1', scopeType: 'myScopeType', scopeId: 'scopeId', scopeName: 'scope name', time: 0, level: '30' }, - { msg: 'my message 1', scopeType: 'myScopeType', scopeId: 'scopeId', scopeName: 'scope name', time: 1, level: '10' } + { + msg: 'my message 1', + scopeType: 'myScopeType', + scopeId: 'scopeId', + scopeName: 'scope name', + time: '2020-01-01T00:00:00.000Z', + level: '30' + }, + { + msg: 'my message 1', + scopeType: 'myScopeType', + scopeId: 'scopeId', + scopeName: 'scope name', + time: '2020-01-01T01:00:00.000Z', + level: '10' + } ]); expect(database.prepare).toHaveBeenCalledWith( 'INSERT INTO logs (timestamp, level, scope_type, scope_id, scope_name, message) VALUES (?,?,?,?,?,?), (?,?,?,?,?,?);' ); expect(run).toHaveBeenCalledWith( - 0, + '2020-01-01T00:00:00.000Z', 'info', 'myScopeType', 'scopeId', 'scope name', 'my message 1', - 1, + '2020-01-01T01:00:00.000Z', 'trace', 'myScopeType', 'scopeId', diff --git a/backend/src/service/logger/logger.service.spec.ts b/backend/src/service/logger/logger.service.spec.ts index b208880c96..b9951ab133 100644 --- a/backend/src/service/logger/logger.service.spec.ts +++ b/backend/src/service/logger/logger.service.spec.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import EncryptionServiceMock from '../../tests/__mocks__/encryption-service.mock'; -import { LogSettings } from '../../../../shared/model/engine.model'; +import { LogSettings, RegistrationSettingsDTO } from '../../../../shared/model/engine.model'; import pino from 'pino'; @@ -59,12 +59,12 @@ describe('Logger', () => { level: 'debug', username: 'user', password: 'loki-pass', - tokenAddress: 'token-url', address: 'loki-url', interval: 60 }, oia: { - level: 'error' + level: 'error', + interval: 60 } }; service = new LoggerService(encryptionService, 'folder'); @@ -72,6 +72,17 @@ describe('Logger', () => { it('should be properly initialized', async () => { service.createChildLogger = jest.fn(); + const registration: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + proxyUrl: 'http://localhost:9000', + proxyUsername: 'username', + proxyPassword: 'password', + token: 'token', + status: 'REGISTERED' + } as RegistrationSettingsDTO; const expectedTargets = [ { target: 'pino-pretty', options: { colorize: true, singleLine: true }, level: logSettings.console.level }, { @@ -91,21 +102,36 @@ describe('Logger', () => { level: logSettings.database.level }, { - target: path.join(__dirname, 'loki-transport.js'), + target: 'pino-loki', options: { - username: logSettings.loki.username, - password: logSettings.loki.password, - tokenAddress: logSettings.loki.tokenAddress, - address: logSettings.loki.address, - id: oibusId, - name: oibusName, - interval: logSettings.loki.interval + batching: true, + interval: logSettings.loki.interval, + host: logSettings.loki.address, + basicAuth: { + username: logSettings.loki.username, + password: logSettings.loki.password + }, + labels: { name: oibusName } }, level: logSettings.loki.level + }, + { + target: path.join(__dirname, 'oianalytics-transport.js'), + options: { + interval: logSettings.oia.interval, + host: registration.host, + token: registration.token, + useProxy: registration.useProxy, + proxyUrl: registration.proxyUrl, + proxyUsername: registration.proxyUsername, + proxyPassword: registration.proxyPassword, + acceptUnauthorized: registration.acceptUnauthorized + }, + level: logSettings.oia.level } ]; - await service.start(oibusId, oibusName, logSettings); + await service.start(oibusId, oibusName, logSettings, registration); expect(pino).toHaveBeenCalledTimes(1); expect(pino).toHaveBeenCalledWith({ @@ -117,22 +143,39 @@ describe('Logger', () => { }); it('should be properly initialized with loki error and standard file names', async () => { + const registration: RegistrationSettingsDTO = { + id: 'id', + status: 'REGISTERED', + token: 'token' + } as RegistrationSettingsDTO; service.createChildLogger = jest.fn(); - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); (encryptionService.decryptText as jest.Mock).mockImplementation(() => { throw new Error('decrypt-error'); }); logSettings.database.maxNumberOfLogs = 0; - await service.start(oibusId, oibusName, logSettings); + await service.start(oibusId, oibusName, logSettings, registration); - expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledTimes(2); expect(console.error).toHaveBeenCalledWith(new Error('decrypt-error')); }); - it('should be properly initialized without loki password and without sqliteLog', async () => { + it('should be properly initialized without loki password, without oia token and without sqliteLog', async () => { + const registration: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: true, + proxyUrl: 'http://localhost:9000', + proxyUsername: 'username', + proxyPassword: '', + token: '', + status: 'REGISTERED' + } as RegistrationSettingsDTO; + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); service.createChildLogger = jest.fn(); @@ -149,21 +192,36 @@ describe('Logger', () => { level: logSettings.file.level }, { - target: path.join(__dirname, 'loki-transport.js'), + target: 'pino-loki', options: { - username: logSettings.loki.username, - password: logSettings.loki.password, - tokenAddress: logSettings.loki.tokenAddress, - address: logSettings.loki.address, - id: oibusId, - name: oibusName, - interval: logSettings.loki.interval + batching: true, + interval: logSettings.loki.interval, + host: logSettings.loki.address, + basicAuth: { + username: logSettings.loki.username, + password: logSettings.loki.password + }, + labels: { name: oibusName } }, level: logSettings.loki.level + }, + { + target: path.join(__dirname, 'oianalytics-transport.js'), + options: { + interval: logSettings.oia.interval, + host: registration.host, + token: registration.token, + useProxy: registration.useProxy, + proxyUrl: registration.proxyUrl, + proxyUsername: registration.proxyUsername, + proxyPassword: registration.proxyPassword, + acceptUnauthorized: registration.acceptUnauthorized + }, + level: logSettings.oia.level } ]; - await service.start(oibusId, oibusName, logSettings); + await service.start(oibusId, oibusName, logSettings, registration); expect(pino).toHaveBeenCalledTimes(1); expect(pino).toHaveBeenCalledWith({ @@ -174,7 +232,11 @@ describe('Logger', () => { }); }); - it('should be properly initialized without lokiLog nor sqliteLog', async () => { + it('should be properly initialized without lokiLog, nor oianalytics nor sqliteLog', async () => { + const registration: RegistrationSettingsDTO = { + status: 'NOT_REGISTERED' + } as RegistrationSettingsDTO; + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); service.createChildLogger = jest.fn(); @@ -192,7 +254,7 @@ describe('Logger', () => { } ]; - await service.start(oibusId, oibusName, logSettings); + await service.start(oibusId, oibusName, logSettings, registration); expect(pino).toHaveBeenCalledTimes(1); expect(pino).toHaveBeenCalledWith({ diff --git a/backend/src/service/logger/logger.service.ts b/backend/src/service/logger/logger.service.ts index f5cd073507..9a31c280bf 100644 --- a/backend/src/service/logger/logger.service.ts +++ b/backend/src/service/logger/logger.service.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import pino from 'pino'; -import { LogSettings, ScopeType } from '../../../../shared/model/engine.model'; +import { LogSettings, RegistrationSettingsDTO, ScopeType } from '../../../../shared/model/engine.model'; import FileCleanupService from './file-cleanup.service'; import EncryptionService from '../encryption.service'; @@ -31,7 +31,7 @@ class LoggerService { /** * Run the appropriate pino log transports according to the configuration */ - async start(oibusId: string, oibusName: string, logParameters: LogSettings): Promise<void> { + async start(oibusId: string, oibusName: string, logParameters: LogSettings, registration: RegistrationSettingsDTO | null): Promise<void> { const targets = []; targets.push({ target: 'pino-pretty', options: { colorize: true, singleLine: true }, level: logParameters.console.level }); @@ -58,15 +58,16 @@ class LoggerService { if (logParameters.loki.address) { try { targets.push({ - target: path.join(__dirname, './loki-transport.js'), + target: 'pino-loki', options: { - username: logParameters.loki.username, - password: logParameters.loki.password ? await this.encryptionService.decryptText(logParameters.loki.password) : '', - address: logParameters.loki.address, - tokenAddress: logParameters.loki.tokenAddress, - id: oibusId, - name: oibusName, - interval: logParameters.loki.interval + batching: true, + interval: logParameters.loki.interval, + host: logParameters.loki.address, + basicAuth: { + username: logParameters.loki.username, + password: logParameters.loki.password ? await this.encryptionService.decryptText(logParameters.loki.password) : '' + }, + labels: { name: oibusName } }, level: logParameters.loki.level }); @@ -77,6 +78,29 @@ class LoggerService { } } + if (registration && registration.status === 'REGISTERED') { + try { + targets.push({ + target: path.join(__dirname, './oianalytics-transport.js'), + options: { + interval: logParameters.oia.interval, + host: registration.host, + token: registration.token ? await this.encryptionService.decryptText(registration.token) : '', + useProxy: registration.useProxy, + proxyUrl: registration.proxyUrl, + proxyUsername: registration.proxyUsername, + proxyPassword: registration.proxyPassword ? await this.encryptionService.decryptText(registration.proxyPassword) : '', + acceptUnauthorized: registration.acceptUnauthorized + }, + level: logParameters.oia.level + }); + } catch (error) { + // In case of bad decryption, an error is triggered, so instead of leaving the process, the error will just be + // logged in the console and loki won't be activated + console.error(error); + } + } + this.logger = pino({ base: undefined, level: 'trace', // default to trace since each transport has its defined level diff --git a/backend/src/service/logger/loki-transport.ts b/backend/src/service/logger/loki-transport.ts deleted file mode 100644 index 80b4199c09..0000000000 --- a/backend/src/service/logger/loki-transport.ts +++ /dev/null @@ -1,220 +0,0 @@ -import fetch from 'node-fetch'; -import build from 'pino-abstract-transport'; - -import { LogStreamValuesCommandDTO, PinoLog } from '../../../../shared/model/logs.model'; -import { LogLevel } from '../../../../shared/model/engine.model'; - -const MAX_BATCH_LOG = 500; -const MAX_BATCH_INTERVAL_S = 60; -const LEVEL_FORMAT: { [key: string]: LogLevel } = { - '10': 'trace', - '20': 'debug', - '30': 'info', - '40': 'warn', - '50': 'error', - '60': 'fatal' -}; - -interface LokiOptions { - username?: string; - password?: string; - tokenAddress?: string; - address: string; - id: string; - oibusName: string; - interval?: number; - batchLimit?: number; -} - -interface AccessToken { - access_token: string; -} - -/** - * Class to support logging to a remote loki instance as a custom Pino Transport module - */ -class LokiTransport { - private readonly options: LokiOptions; - private token: AccessToken | null = null; - private mustRenewToken = true; - private mustRenewTokenTimeout: NodeJS.Timeout | null = null; - private sendLokiLogsInterval: NodeJS.Timeout | null = null; - private batchLogs: { [key: string]: Array<[string, string]> }; - private numberOfLogs = 0; - - constructor(options: LokiOptions) { - this.options = options; - this.batchLogs = { - '60': [], - '50': [], - '40': [], - '30': [], - '20': [], - '10': [] - }; - - this.sendLokiLogsInterval = setInterval( - async () => { - await this.sendLokiLogs(); - }, - (this.options.interval || MAX_BATCH_INTERVAL_S) * 1000 - ); - } - - /** - * Method used to send the log to the remote loki instance - */ - sendLokiLogs = async (): Promise<void> => { - const streams: Array<LogStreamValuesCommandDTO> = []; - Object.entries(this.batchLogs).forEach(([logLevel, logMessages]) => { - if (logMessages.length > 0) { - logMessages.forEach(logMessage => { - const jsonMessage = JSON.parse(logMessage[1]); - streams.push({ - stream: { - oibus: this.options.id, - oibusName: this.options.oibusName, - level: LEVEL_FORMAT[logLevel], - scopeType: jsonMessage.scopeType, - scopeId: jsonMessage.scopeId, - scopeName: jsonMessage.scopeName - }, - values: [[logMessage[0], jsonMessage.message]] - }); - }); - } - }); - if (streams.length === 0) { - return; - } - const dataBuffer = JSON.stringify({ streams }); - this.batchLogs = { - '60': [], - '50': [], - '40': [], - '30': [], - '20': [], - '10': [] - }; - this.numberOfLogs = 0; - - const fetchOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: '' }, - body: dataBuffer - }; - - if (this.options.tokenAddress) { - if (this.mustRenewToken || !this.token) { - await this.updateLokiToken(); - } - if (!this.token) { - return; - } - fetchOptions.headers.Authorization = `Bearer ${this.token.access_token}`; - } else if (this.options.username && this.options.password) { - const basicAuth = Buffer.from(`${this.options.username}:${this.options.password}`).toString('base64'); - fetchOptions.headers.Authorization = `Basic ${basicAuth}`; - } - try { - const result = await fetch(this.options.address, fetchOptions); - if (result.status !== 200 && result.status !== 201 && result.status !== 204) { - console.error(`Loki fetch error: ${result.status} - ${result.statusText} with payload ${dataBuffer}`); - } - } catch (error) { - console.error(error); - } - }; - - /** - * Method used to update the token if needed - */ - updateLokiToken = async (): Promise<void> => { - if (!this.options.tokenAddress || !this.options.username || !this.options.password) { - return; - } - try { - const basic = Buffer.from(`${this.options.username}:${this.options.password}`).toString('base64'); - const fetchOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${basic}` - }, - timeout: 10000 - }; - const response = await fetch(this.options.tokenAddress, fetchOptions); - const responseData = (await response.json()) as { expires_in: number; access_token: string }; - - if (this.mustRenewTokenTimeout) { - clearTimeout(this.mustRenewTokenTimeout); - } - this.mustRenewTokenTimeout = setTimeout( - () => { - this.mustRenewToken = true; - }, - (responseData.expires_in - 60) * 1000 - ); - this.token = responseData; - this.mustRenewToken = false; - } catch (error) { - console.error(error); - } - }; - - /** - * Store the log in the batch log array and send them immediately if the array is full - */ - addLokiLogs = async (log: PinoLog): Promise<void> => { - this.batchLogs[log.level].push([ - (new Date(log.time).getTime() * 1000000).toString(), - JSON.stringify({ message: log.msg, scopeType: log.scopeType, scopeId: log.scopeId, scopeName: log.scopeName }) - ]); - this.numberOfLogs += 1; - const batchLimit = this.options.batchLimit || MAX_BATCH_LOG; - if (this.numberOfLogs >= batchLimit) { - if (this.sendLokiLogsInterval) { - clearInterval(this.sendLokiLogsInterval); - } - await this.sendLokiLogs(); - - this.sendLokiLogsInterval = setInterval( - async () => { - await this.sendLokiLogs(); - }, - (this.options.interval || MAX_BATCH_INTERVAL_S) * 1000 - ); - } - }; - - /** - * Clear timeout and interval and send last logs before closing the transport - */ - end = async (): Promise<void> => { - if (this.sendLokiLogsInterval) { - clearInterval(this.sendLokiLogsInterval); - } - await this.sendLokiLogs(); - if (this.mustRenewTokenTimeout) { - clearTimeout(this.mustRenewTokenTimeout); - } - }; -} - -const createTransport = async (opts: LokiOptions) => { - const lokiTransport = new LokiTransport(opts); - return build( - async source => { - for await (const log of source) { - await lokiTransport.addLokiLogs(log); - } - }, - { - close: async () => { - await lokiTransport.end(); - } - } - ); -}; - -export default createTransport; diff --git a/backend/src/service/logger/oianalytics-transport.ts b/backend/src/service/logger/oianalytics-transport.ts new file mode 100644 index 0000000000..ba74d5f78f --- /dev/null +++ b/backend/src/service/logger/oianalytics-transport.ts @@ -0,0 +1,156 @@ +import build from 'pino-abstract-transport'; + +import { LogDTO, PinoLog } from '../../../../shared/model/logs.model'; +import { LogLevel, ScopeType } from '../../../../shared/model/engine.model'; +import { createProxyAgent } from '../proxy-agent'; +import fetch, { HeadersInit } from 'node-fetch'; + +const MAX_BATCH_LOG = 500; +const MAX_BATCH_INTERVAL_S = 60; +const LEVEL_FORMAT: { [key: string]: LogLevel } = { + '10': 'TRACE', + '20': 'DEBUG', + '30': 'INFO', + '40': 'WARN', + '50': 'ERROR' +}; + +const SCOPE_TYPE_FORMAT: { [key: ScopeType]: LogLevel } = { + south: 'SOUTH', + north: 'NORTH', + 'history-query': 'HISTORY_QUERY', + internal: 'INTERNAL', + 'web-server': 'WEB_SERVER' +}; + +interface OIAnalyticsOptions { + interval: number; + host: string; + token: string; + useProxy: boolean; + proxyUrl?: string; + proxyUsername?: string | null; + proxyPassword?: string | null; + acceptUnauthorized: boolean; + batchLimit?: number; +} + +/** + * Class to support logging to OIAnalytics + */ +class OianalyticsTransport { + private readonly options: OIAnalyticsOptions; + private sendOIALogsInterval: NodeJS.Timeout | null = null; + private batchLogs: Array<LogDTO> = []; + + constructor(options: OIAnalyticsOptions) { + this.options = options; + if (this.options.host.endsWith('/')) { + this.options.host = this.options.host.slice(0, this.options.host.length - 1); + } + + const batchInterval = this.options.interval > MAX_BATCH_INTERVAL_S ? MAX_BATCH_INTERVAL_S : this.options.interval; + this.sendOIALogsInterval = setInterval( + async () => { + await this.sendOIALogs(); + }, + (batchInterval || MAX_BATCH_INTERVAL_S) * 1000 + ); + } + + /** + * Method used to send the log to OIAnalytics + */ + sendOIALogs = async (): Promise<void> => { + const headers: HeadersInit = {}; + headers.authorization = `Bearer ${this.options.token}`; + headers['Content-Type'] = 'application/json'; + const endpoint = '/api/oianalytics/oibus/logs'; + const agent = createProxyAgent( + this.options.useProxy, + `${this.options.host}${endpoint}`, + this.options.useProxy + ? { + url: this.options.proxyUrl!, + username: this.options.proxyUsername || null, + password: this.options.proxyPassword || null + } + : null, + this.options.acceptUnauthorized + ); + + const dataBuffer = JSON.stringify(this.batchLogs); + this.batchLogs = []; + const fetchOptions = { + method: 'POST', + headers, + body: dataBuffer, + agent + }; + + const logUrl = `${this.options.host}${endpoint}`; + try { + const response = await fetch(logUrl, fetchOptions); + if (response.status !== 200 && response.status !== 201 && response.status !== 204) { + console.error(`OIAnalytics fetch error on ${logUrl}: ${response.status} - ${response.statusText} with payload ${dataBuffer}`); + } + } catch (error) { + console.error(`Error when sending logs to ${logUrl}. ${error}`); + } + }; + + /** + * Store the log in the batch log array and send them immediately if the array is full + */ + addLogs = async (log: PinoLog): Promise<void> => { + this.batchLogs.push({ + timestamp: log.time, + level: LEVEL_FORMAT[log.level], + scopeType: SCOPE_TYPE_FORMAT[log.scopeType], + scopeId: log.scopeId || undefined, + scopeName: log.scopeName || undefined, + message: log.msg + }); + const batchLimit = this.options.batchLimit || MAX_BATCH_LOG; + if (this.batchLogs.length >= batchLimit) { + if (this.sendOIALogsInterval) { + clearInterval(this.sendOIALogsInterval); + this.sendOIALogsInterval = null; + } + await this.sendOIALogs(); + + const batchInterval = this.options.interval > MAX_BATCH_INTERVAL_S ? MAX_BATCH_INTERVAL_S : this.options.interval; + this.sendOIALogsInterval = setInterval(async () => { + await this.sendOIALogs(); + }, batchInterval * 1000); + } + }; + + /** + * Clear timeout and interval and send last logs before closing the transport + */ + end = async (): Promise<void> => { + if (this.sendOIALogsInterval) { + clearInterval(this.sendOIALogsInterval); + } + await this.sendOIALogs(); + }; +} + +const createTransport = async (opts: OIAnalyticsOptions) => { + const oianalyticsTransport = new OianalyticsTransport(opts); + return build( + async source => { + for await (const log of source) { + await oianalyticsTransport.addLogs(log); + } + }, + { + close: async () => { + await oianalyticsTransport.end(); + } + } + ); +}; + +export default createTransport; diff --git a/backend/src/service/command.service.spec.ts b/backend/src/service/oia/command.service.spec.ts similarity index 88% rename from backend/src/service/command.service.spec.ts rename to backend/src/service/oia/command.service.spec.ts index c8e0315967..f546643298 100644 --- a/backend/src/service/command.service.spec.ts +++ b/backend/src/service/oia/command.service.spec.ts @@ -1,19 +1,19 @@ -import RepositoryService from './repository.service'; -import RepositoryServiceMock from '../tests/__mocks__/repository-service.mock'; -import EncryptionServiceMock from '../tests/__mocks__/encryption-service.mock'; -import EncryptionService from './encryption.service'; +import RepositoryService from '../repository.service'; +import RepositoryServiceMock from '../../tests/__mocks__/repository-service.mock'; +import EncryptionServiceMock from '../../tests/__mocks__/encryption-service.mock'; +import EncryptionService from '../encryption.service'; import pino from 'pino'; -import PinoLogger from '../tests/__mocks__/logger.mock'; -import { OIBusCommandDTO } from '../../../shared/model/command.model'; +import PinoLogger from '../../tests/__mocks__/logger.mock'; +import { OIBusCommandDTO } from '../../../../shared/model/command.model'; import CommandService from './command.service'; -import { downloadFile, getNetworkSettingsFromRegistration, getOIBusInfo, unzip } from './utils'; -import { RegistrationSettingsDTO } from '../../../shared/model/engine.model'; +import { downloadFile, getNetworkSettingsFromRegistration, getOIBusInfo, unzip } from '../utils'; +import { RegistrationSettingsDTO } from '../../../../shared/model/engine.model'; import fs from 'node:fs/promises'; import path from 'node:path'; jest.mock('node:fs/promises'); jest.mock('node-fetch'); -jest.mock('./utils'); +jest.mock('../utils'); // @ts-ignore jest.spyOn(process, 'exit').mockImplementation(() => {}); @@ -23,6 +23,7 @@ const encryptionService: EncryptionService = new EncryptionServiceMock('', ''); const nowDateString = '2020-02-02T02:02:02.222Z'; const logger: pino.Logger = new PinoLogger(); +const anotherLogger: pino.Logger = new PinoLogger(); const command: OIBusCommandDTO = { id: 'id1', @@ -58,7 +59,7 @@ describe('Command service with running command', () => { (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(registration); (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - service = new CommandService('oibusId', repositoryService, encryptionService, logger, 'binaryFolder'); + service = new CommandService(repositoryService, encryptionService, logger, 'binaryFolder'); }); it('should properly start and stop', async () => { @@ -127,7 +128,7 @@ describe('Command service without command', () => { jest.clearAllMocks(); (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([]); - service = new CommandService('oibusId', repositoryService, encryptionService, logger, 'binaryFolder'); + service = new CommandService(repositoryService, encryptionService, logger, 'binaryFolder'); }); it('should properly start when not registered', () => { @@ -202,4 +203,8 @@ describe('Command service without command', () => { expect(unzip).toHaveBeenCalledWith(expectedFilename, path.resolve('binaryFolder', '..', 'update')); expect(fs.unlink).toHaveBeenCalledWith(expectedFilename); }); + + it('should change logger', () => { + service.setLogger(anotherLogger); + }); }); diff --git a/backend/src/service/command.service.ts b/backend/src/service/oia/command.service.ts similarity index 91% rename from backend/src/service/command.service.ts rename to backend/src/service/oia/command.service.ts index a53f62c576..28b3515601 100644 --- a/backend/src/service/command.service.ts +++ b/backend/src/service/oia/command.service.ts @@ -1,13 +1,13 @@ import fs from 'node:fs/promises'; -import { delay, downloadFile, getNetworkSettingsFromRegistration, getOIBusInfo, unzip } from './utils'; -import RepositoryService from './repository.service'; -import EncryptionService from './encryption.service'; +import { delay, downloadFile, getNetworkSettingsFromRegistration, getOIBusInfo, unzip } from '../utils'; +import RepositoryService from '../repository.service'; +import EncryptionService from '../encryption.service'; import pino from 'pino'; -import { OIBusCommandDTO } from '../../../shared/model/command.model'; +import { OIBusCommandDTO } from '../../../../shared/model/command.model'; import { EventEmitter } from 'node:events'; -import DeferredPromise from './deferred-promise'; +import DeferredPromise from '../deferred-promise'; import { DateTime } from 'luxon'; -import { RegistrationSettingsDTO } from '../../../shared/model/engine.model'; +import { RegistrationSettingsDTO } from '../../../../shared/model/engine.model'; import path from 'node:path'; const DOWNLOAD_TIMEOUT = 600_000; @@ -19,7 +19,6 @@ export default class CommandService { private runProgress$: DeferredPromise | null = null; constructor( - private oibusId: string, private repositoryService: RepositoryService, private encryptionService: EncryptionService, private logger: pino.Logger, @@ -136,4 +135,8 @@ export default class CommandService { } this.logger.debug(`Command service stopped`); } + + setLogger(logger: pino.Logger) { + this.logger = logger; + } } diff --git a/backend/src/service/oia/registration.service.spec.ts b/backend/src/service/oia/registration.service.spec.ts new file mode 100644 index 0000000000..8212b0c637 --- /dev/null +++ b/backend/src/service/oia/registration.service.spec.ts @@ -0,0 +1,865 @@ +import fetch from 'node-fetch'; +import RepositoryService from '../repository.service'; +import RepositoryServiceMock from '../../tests/__mocks__/repository-service.mock'; +import { EngineSettingsDTO, RegistrationSettingsCommandDTO, RegistrationSettingsDTO } from '../../../../shared/model/engine.model'; +import EncryptionServiceMock from '../../tests/__mocks__/encryption-service.mock'; +import EncryptionService from '../encryption.service'; +import pino from 'pino'; +import PinoLogger from '../../tests/__mocks__/logger.mock'; +import { createProxyAgent } from '../proxy-agent'; +import { OIBusCommandDTO } from '../../../../shared/model/command.model'; +import { generateRandomId, getNetworkSettingsFromRegistration, getOIBusInfo } from '../utils'; +import CommandService from './command.service'; +import CommandServiceMock from '../../tests/__mocks__/command-service.mock'; +import RegistrationService from './registration.service'; +import ReloadServiceMock from '../../tests/__mocks__/reload-service.mock'; +import ReloadService from '../reload.service'; + +jest.mock('node:fs/promises'); +jest.mock('node-fetch'); +const { Response } = jest.requireActual('node-fetch'); +jest.mock('../utils'); +jest.mock('../proxy-agent'); + +const repositoryService: RepositoryService = new RepositoryServiceMock('', ''); +const encryptionService: EncryptionService = new EncryptionServiceMock('', ''); +const commandService: CommandService = new CommandServiceMock(); +const reloadService: ReloadService = new ReloadServiceMock(); + +const nowDateString = '2020-02-02T02:02:02.222Z'; +const logger: pino.Logger = new PinoLogger(); +const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate); + +const command: OIBusCommandDTO = { + id: 'id1', + type: 'UPGRADE', + status: 'COMPLETED', + ack: true, + creationDate: '2023-01-01T12:00:00Z', + completedDate: '2023-01-01T12:00:00Z', + result: 'ok', + version: '3.2.0', + assetId: 'assetId' +}; + +const fakeEngineSettings: EngineSettingsDTO = { + id: 'id1', + name: 'MyOIBus', + logParameters: { + oia: { + level: 'silent' + } + } +} as EngineSettingsDTO; + +let service: RegistrationService; +describe('Registration service', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date(nowDateString)); + (createProxyAgent as jest.Mock).mockReturnValue(undefined); + + service = new RegistrationService(repositoryService, encryptionService, commandService, reloadService, logger); + }); + + it('should get NOT_REGISTERED registration settings', () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + status: 'NOT_REGISTERED', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + service.start(); + const result = service.getRegistrationSettings(); + expect(result).toEqual(mockResult); + expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); + }); + + it('should update registration', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + const fetchResponse = { + redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + expirationDate: '2020-02-02T02:12:02.222Z' + }; + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.updateRegistrationSettings(command); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); + expect(generateRandomId).toHaveBeenCalledWith(6); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( + command, + '1234', + 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + '2020-02-02T02:12:02.222Z' + ); + expect(reloadService.restartLogger).not.toHaveBeenCalled(); + }); + + it('should update registration with proxy', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: true, + proxyUrl: 'http://localhost:3128', + proxyUsername: 'user', + proxyPassword: 'pass' + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + const fetchResponse = { + redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + expirationDate: '2020-02-02T02:12:02.222Z' + }; + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.updateRegistrationSettings(command); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); + expect(generateRandomId).toHaveBeenCalledWith(6); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( + command, + '1234', + 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + '2020-02-02T02:12:02.222Z' + ); + }); + + it('should update registration with proxy and without password', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: true, + proxyUrl: 'http://localhost:3128', + proxyUsername: '', + proxyPassword: '' + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + const fetchResponse = { + redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + expirationDate: '2020-02-02T02:12:02.222Z' + }; + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.updateRegistrationSettings(command); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); + expect(generateRandomId).toHaveBeenCalledWith(6); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( + command, + '1234', + 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + '2020-02-02T02:12:02.222Z' + ); + }); + + it('should handle fetch error during registration update', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + (fetch as unknown as jest.Mock).mockImplementation(() => { + throw new Error('error'); + }); + + let error; + try { + await service.updateRegistrationSettings(command); + } catch (e) { + error = e; + } + expect(error).toEqual(new Error('Registration failed: Error: error')); + }); + + it('should handle fetch bad response during registration update', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); + + let error; + try { + await service.updateRegistrationSettings(command); + } catch (e) { + error = e; + } + expect(error).toEqual(new Error(`Registration failed with status code 404 and message: Not Found`)); + }); + + it('should handle error if registration not found', async () => { + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValueOnce(null); + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false + }; + + let error; + try { + await service.updateRegistrationSettings(command); + } catch (e) { + error = e; + } + expect(error).toEqual(new Error('Registration settings not found')); + }); + + it('should activate registration', async () => { + await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); + expect(repositoryService.registrationRepository.activateRegistration).toHaveBeenCalledWith('2020-20-20T00:00:00.000Z', 'token'); + }); + + it('should unregister', async () => { + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(fakeEngineSettings); + + await service.onUnregister(); + expect(repositoryService.registrationRepository.unregister).toHaveBeenCalledTimes(1); + }); + + it('should check registration', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + await service.checkRegistration(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); + expect(reloadService.restartLogger).not.toHaveBeenCalled(); + }); + + it('should check registration and return because already checking', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValue(fakeEngineSettings); + const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + service.checkRegistration(); + await service.checkRegistration(); + expect(logger.trace).toHaveBeenCalledWith('On going registration check'); + await flushPromises(); + }); + + it('should check registration but fail because of return status', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + const fetchResponse = { status: 'DECLINED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + await service.checkRegistration(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(service.activateRegistration).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith(`Registration not completed. Status: DECLINED`); + await service.checkRegistration(); + }); + + it('should check registration but fail because of fetch response', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); + await service.checkRegistration(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(logger.error).toHaveBeenCalledWith( + `Error 404 while checking registration status on ${mockResult.host}${mockResult.checkUrl}: Not Found` + ); + }); + + it('should check registration and fail when registration check url not set', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + await service.checkRegistration(); + expect(logger.error).toHaveBeenCalledWith('Error while checking registration status: Could not retrieve check URL'); + }); + + it('should check registration and fail on fetch error', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: 'check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + (fetch as unknown as jest.Mock).mockImplementation(() => { + throw new Error('error'); + }); + await service.checkRegistration(); + expect(logger.error).toHaveBeenCalledWith( + `Error while checking registration status on ${mockResult.host}${mockResult.checkUrl}. Error: error` + ); + }); + + it('should check registration with proxy', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: true, + proxyUrl: 'http://localhost:3128', + proxyUsername: 'user', + proxyPassword: 'pass', + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + await service.checkRegistration(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); + }); + + it('should check registration with proxy and without password', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: true, + proxyUrl: 'http://localhost:3128', + proxyUsername: '', + proxyPassword: '', + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + await service.checkRegistration(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); + }); + + it('should check commands', async () => { + service.checkRetrievedCommands = jest.fn(); + service.retrieveCommands = jest.fn(); + service.sendAckCommands = jest.fn(); + + await service.checkCommands(); + expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); + expect(service.retrieveCommands).toHaveBeenCalledTimes(1); + expect(service.sendAckCommands).toHaveBeenCalledTimes(1); + expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); + expect(service.retrieveCommands).toHaveBeenCalledTimes(1); + expect(service.sendAckCommands).toHaveBeenCalledTimes(1); + }); + + it('should check comm ands and return because already checking', async () => { + service.checkRetrievedCommands = jest.fn().mockImplementation(() => { + return new Promise<void>(resolve => { + setTimeout(resolve, 1000); + }); + }); + service.retrieveCommands = jest.fn(); + service.sendAckCommands = jest.fn(); + + service.checkCommands(); + await service.checkCommands(); + expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); + expect(service.retrieveCommands).not.toHaveBeenCalled(); + expect(logger.trace).toHaveBeenCalledWith('On going commands check'); + await flushPromises(); + }); +}); + +describe('Registration service with PENDING registration', () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + status: 'PENDING', + checkUrl: 'http://localhost:4200/check/url', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date(nowDateString)); + (createProxyAgent as jest.Mock).mockReturnValue(undefined); + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + service = new RegistrationService(repositoryService, encryptionService, commandService, reloadService, logger); + }); + + it('should get PENDING registration settings', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + + const result = service.getRegistrationSettings(); + expect(result).toEqual(mockResult); + expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); + }); + + it('should stop and clear interval', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + await service.stop(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should activate registration and clear interval', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should unregister and clear interval', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + service.unregister(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('Registration service with REGISTERED registration', () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + status: 'REGISTERED', + checkUrl: 'http://localhost:4200/check/url', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date(nowDateString)); + (createProxyAgent as jest.Mock).mockReturnValue(undefined); + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + service = new RegistrationService(repositoryService, encryptionService, commandService, reloadService, logger); + }); + + it('should get REGISTERED registration settings', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + + const result = service.getRegistrationSettings(); + expect(result).toEqual(mockResult); + expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); + }); + + it('should stop and clear interval', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + await service.stop(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should activate registration and clear interval', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should unregister and clear interval', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + service.start(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + service.unregister(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('OIBus service should interact with OIA and', () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + status: 'REGISTERED', + checkUrl: 'http://localhost:4200/check/url', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + const mockEngineSettings: EngineSettingsDTO = { + id: 'id1', + name: 'MyOIBus', + logParameters: { + oia: { + level: 'error' + } + } + } as EngineSettingsDTO; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date(nowDateString)); + (createProxyAgent as jest.Mock).mockReturnValue(undefined); + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValue(mockEngineSettings); + (getNetworkSettingsFromRegistration as jest.Mock).mockReturnValue({ host: 'http://localhost:4200', headers: {}, agent: undefined }); + service = new RegistrationService(repositoryService, encryptionService, commandService, reloadService, logger); + }); + + it('should ack commands and return if no commands in OIBus', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([]); + + await service.sendAckCommands(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should ack commands', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + const fetchResponse: Array<OIBusCommandDTO> = [{ id: 'id1' }] as Array<OIBusCommandDTO>; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.sendAckCommands(); + expect(logger.trace).toHaveBeenCalledWith(`1 commands acknowledged`); + expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/status', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify([command]), + timeout: 10000, + agent: undefined + }); + expect(repositoryService.commandRepository.markAsAcknowledged).toHaveBeenCalledWith('id1'); + }); + + it('should ack commands and manage 404 error', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); + + await service.sendAckCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error 404 while acknowledging 1 commands on http://localhost:4200/api/oianalytics/oibus/commands/status: Not Found` + ); + }); + + it('should ack commands and log error on fetch error', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + (fetch as unknown as jest.Mock).mockImplementation(() => { + throw new Error('error'); + }); + + await service.sendAckCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error while acknowledging 1 commands on http://localhost:4200/api/oianalytics/oibus/commands/status. ${new Error('error')}` + ); + }); + + it('should check cancelled commands and return if no commands in OIBus', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([]); + + await service.checkRetrievedCommands(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should check cancelled commands and no command retrieved from oia', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + const fetchResponse: Array<OIBusCommandDTO> = []; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.checkRetrievedCommands(); + expect(logger.trace).toHaveBeenCalledWith(`No command cancelled among the 1 commands`); + expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1', { + method: 'GET', + headers: {}, + timeout: 10000, + agent: undefined + }); + }); + + it('should check cancelled commands and cancel retrieved commands', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + const fetchResponse: Array<OIBusCommandDTO> = [{ id: 'id1' }] as Array<OIBusCommandDTO>; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.checkRetrievedCommands(); + expect(logger.trace).toHaveBeenCalledWith(`1 commands cancelled among the 1 pending commands`); + expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1', { + method: 'GET', + headers: {}, + timeout: 10000, + agent: undefined + }); + expect(repositoryService.commandRepository.cancel).toHaveBeenCalledWith('id1'); + }); + + it('should check cancelled commands log error if fetch response not ok', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); + + await service.checkRetrievedCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error 404 while checking PENDING commands status on http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1: Not Found` + ); + }); + + it('should check cancelled commands log error on fetch error', async () => { + (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); + + (fetch as unknown as jest.Mock).mockImplementation(() => { + throw new Error('error'); + }); + + await service.checkRetrievedCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error while checking PENDING commands status on http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1. ${new Error( + 'error' + )}` + ); + }); + + it('should retrieve commands and trace logs if no command retrieved', async () => { + const fetchResponse: Array<OIBusCommandDTO> = []; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.retrieveCommands(); + expect(logger.trace).toHaveBeenCalledWith(`No command to create`); + expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/pending', { + method: 'GET', + headers: {}, + timeout: 10000, + agent: undefined + }); + }); + + it('should retrieve and create commands', async () => { + const fetchResponse: Array<OIBusCommandDTO> = [command]; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.retrieveCommands(); + expect(logger.trace).toHaveBeenCalledWith(`1 commands to add`); + expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/pending', { + method: 'GET', + headers: {}, + timeout: 10000, + agent: undefined + }); + expect(repositoryService.commandRepository.create).toHaveBeenCalledWith('id1', { + type: command.type, + version: command.version, + assetId: command.assetId + }); + }); + + it('should retrieve log error on bad fetch response', async () => { + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); + + await service.retrieveCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error 404 while retrieving commands on http://localhost:4200/api/oianalytics/oibus/commands/pending: Not Found` + ); + }); + + it('should retrieve log error on fetch error', async () => { + (fetch as unknown as jest.Mock).mockImplementation(() => { + throw new Error('error'); + }); + + await service.retrieveCommands(); + expect(logger.error).toHaveBeenCalledWith( + `Error while retrieving commands on http://localhost:4200/api/oianalytics/oibus/commands/pending. ${new Error('error')}` + ); + }); + + it('should unregister', async () => { + service.unregister = jest.fn(); + await service.onUnregister(); + expect(service.unregister).toHaveBeenCalledTimes(1); + expect(reloadService.restartLogger).toHaveBeenCalledTimes(1); + }); + + it('should update registration', async () => { + (generateRandomId as jest.Mock).mockReturnValue('1234'); + + const command: RegistrationSettingsCommandDTO = { + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false + }; + (getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce(mockEngineSettings); + const fetchResponse = { + redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + expirationDate: '2020-02-02T02:12:02.222Z' + }; + + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + + await service.updateRegistrationSettings(command); + expect(reloadService.restartLogger).toHaveBeenCalledTimes(1); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); + expect(generateRandomId).toHaveBeenCalledWith(6); + expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( + command, + '1234', + 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', + '2020-02-02T02:12:02.222Z' + ); + }); + + it('should check registration', async () => { + const mockResult: RegistrationSettingsDTO = { + id: 'id', + host: 'http://localhost:4200', + acceptUnauthorized: false, + useProxy: false, + token: 'token', + activationCode: '1234', + checkUrl: '/check/url', + status: 'PENDING', + activationDate: '2020-20-20T00:00:00.000Z', + activationExpirationDate: '' + }; + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); + (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValue(mockEngineSettings); + const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; + (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); + service.activateRegistration = jest.fn(); + + await service.checkRegistration(); + expect(reloadService.restartLogger).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); + expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); + }); +}); diff --git a/backend/src/service/oia/registration.service.ts b/backend/src/service/oia/registration.service.ts new file mode 100644 index 0000000000..94a5b3d8c7 --- /dev/null +++ b/backend/src/service/oia/registration.service.ts @@ -0,0 +1,347 @@ +import { getNetworkSettingsFromRegistration, getOIBusInfo, generateRandomId } from '../utils'; +import RepositoryService from '../repository.service'; +import EncryptionService from '../encryption.service'; +import pino from 'pino'; +import { OIBusCommandDTO, OIBusCommand } from '../../../../shared/model/command.model'; +import { DateTime } from 'luxon'; +import { RegistrationSettingsDTO, RegistrationSettingsCommandDTO } from '../../../../shared/model/engine.model'; +import { createProxyAgent } from '../proxy-agent'; +import fetch from 'node-fetch'; +import { Instant } from '../../../../shared/model/types'; +import CommandService from './command.service'; +import ReloadService from '../reload.service'; + +const CHECK_TIMEOUT = 10_000; +export default class RegistrationService { + private intervalCheckRegistration: NodeJS.Timeout | null = null; + private intervalCheckCommands: NodeJS.Timeout | null = null; + private ongoingCheckRegistration = false; + private ongoingCheckCommands = false; + + constructor( + private repositoryService: RepositoryService, + private encryptionService: EncryptionService, + private commandService: CommandService, + private reloadService: ReloadService, + private logger: pino.Logger + ) {} + + start() { + const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings(); + if (registrationSettings && registrationSettings.checkUrl && registrationSettings.status === 'PENDING') { + this.intervalCheckRegistration = setInterval(this.checkRegistration.bind(this), CHECK_TIMEOUT); + } + if (registrationSettings && registrationSettings.status === 'REGISTERED') { + this.intervalCheckCommands = setInterval(this.checkCommands.bind(this), CHECK_TIMEOUT); + } + } + + getRegistrationSettings(): RegistrationSettingsDTO | null { + return this.repositoryService.registrationRepository.getRegistrationSettings(); + } + + async updateRegistrationSettings(command: RegistrationSettingsCommandDTO): Promise<void> { + const activationCode = generateRandomId(6); + const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings()!; + if (!registrationSettings) { + throw new Error(`Registration settings not found`); + } + + if (!command.proxyPassword) { + command.proxyPassword = registrationSettings.proxyPassword; + } else { + command.proxyPassword = await this.encryptionService.encryptText(command.proxyPassword); + } + + const engineSettings = this.repositoryService.engineRepository.getEngineSettings()!; + + const oibusInfo = getOIBusInfo(); + const body = { + activationCode, + oibusVersion: oibusInfo.version, + oibusArch: oibusInfo.architecture, + oibusOs: oibusInfo.operatingSystem, + oibusId: engineSettings.id, + oibusName: engineSettings.name + }; + let response; + try { + const url = `${command.host}/api/oianalytics/oibus/registration`; + const agent = createProxyAgent( + command.useProxy, + url, + command.useProxy + ? { + url: command.proxyUrl!, + username: command.proxyUsername!, + password: command.proxyPassword ? await this.encryptionService.decryptText(command.proxyPassword) : null + } + : null, + command.acceptUnauthorized + ); + response = await fetch(url, { + method: 'POST', + timeout: CHECK_TIMEOUT, + agent, + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (fetchError) { + throw new Error(`Registration failed: ${fetchError}`); + } + + if (!response.ok) { + throw new Error(`Registration failed with status code ${response.status} and message: ${response.statusText}`); + } + + const result: { redirectUrl: string; expirationDate: Instant } = await response.json(); + this.repositoryService.registrationRepository.updateRegistration(command, activationCode, result.redirectUrl, result.expirationDate); + if (!this.intervalCheckRegistration) { + this.intervalCheckRegistration = setInterval(this.checkRegistration.bind(this), CHECK_TIMEOUT); + } + + if (engineSettings.logParameters.oia.level !== 'silent') { + await this.reloadService.restartLogger(engineSettings); + } + } + + async activateRegistration(activationDate: string, accessToken: string): Promise<void> { + const encryptedToken = await this.encryptionService.encryptText(accessToken); + this.repositoryService.registrationRepository.activateRegistration(activationDate, encryptedToken); + if (this.intervalCheckRegistration) { + clearInterval(this.intervalCheckRegistration); + this.intervalCheckRegistration = null; + } + + if (this.intervalCheckCommands) { + clearInterval(this.intervalCheckCommands); + this.intervalCheckCommands = null; + } + this.intervalCheckCommands = setInterval(this.checkCommands.bind(this), CHECK_TIMEOUT); + } + + unregister() { + this.repositoryService.registrationRepository.unregister(); + if (this.intervalCheckRegistration) { + clearInterval(this.intervalCheckRegistration); + this.intervalCheckRegistration = null; + } + + if (this.intervalCheckCommands) { + clearInterval(this.intervalCheckCommands); + this.intervalCheckCommands = null; + } + } + + async checkRegistration(): Promise<void> { + if (this.ongoingCheckRegistration) { + this.logger.trace(`On going registration check`); + return; + } + this.logger.trace(`Registration check`); + const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings(); + if (!registrationSettings || !registrationSettings.checkUrl) { + this.logger.error(`Error while checking registration status: Could not retrieve check URL`); + return; + } + let response; + const url = `${registrationSettings.host}${registrationSettings.checkUrl}`; + try { + this.ongoingCheckRegistration = true; + const agent = createProxyAgent( + registrationSettings.useProxy, + url, + registrationSettings.useProxy + ? { + url: registrationSettings.proxyUrl!, + username: registrationSettings.proxyUsername!, + password: registrationSettings.proxyPassword + ? await this.encryptionService.decryptText(registrationSettings.proxyPassword) + : null + } + : null, + registrationSettings.acceptUnauthorized + ); + response = await fetch(url, { + method: 'GET', + timeout: CHECK_TIMEOUT, + agent + }); + if (!response.ok) { + this.logger.error(`Error ${response.status} while checking registration status on ${url}: ${response.statusText}`); + this.ongoingCheckRegistration = false; + return; + } + + const responseData: { status: string; expired: boolean; accessToken: string } = await response.json(); + if (responseData.status !== 'COMPLETED') { + this.logger.warn(`Registration not completed. Status: ${responseData.status}`); + } else { + await this.activateRegistration(DateTime.now().toUTC().toISO()!, responseData.accessToken); + this.logger.info(`OIBus registered on ${registrationSettings.host}`); + const engineSettings = this.repositoryService.engineRepository.getEngineSettings()!; + if (engineSettings.logParameters.oia.level !== 'silent') { + await this.reloadService.restartLogger(engineSettings); + } + } + } catch (fetchError) { + this.logger.error(`Error while checking registration status on ${url}. ${fetchError}`); + } + this.ongoingCheckRegistration = false; + } + + async checkCommands(): Promise<void> { + if (this.ongoingCheckCommands) { + this.logger.trace(`On going commands check`); + return; + } + this.ongoingCheckCommands = true; + + await this.sendAckCommands(); + await this.checkRetrievedCommands(); + await this.retrieveCommands(); + this.ongoingCheckCommands = false; + } + + async sendAckCommands(): Promise<void> { + const commandsToAck = this.repositoryService.commandRepository.searchCommandsList({ + status: [], + types: [], + ack: false + }); + if (commandsToAck.length === 0) { + return; + } + + const endpoint = `/api/oianalytics/oibus/commands/status`; + const registrationSettings = this.getRegistrationSettings(); + const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); + let response; + const url = `${connectionSettings.host}${endpoint}`; + try { + response = await fetch(url, { + method: 'PUT', + body: JSON.stringify(commandsToAck), + headers: { ...connectionSettings.headers, 'Content-Type': 'application/json' }, + timeout: CHECK_TIMEOUT, + agent: connectionSettings.agent + }); + if (!response.ok) { + this.logger.error( + `Error ${response.status} while acknowledging ${commandsToAck.length} commands on ${url}: ${response.statusText}` + ); + return; + } + for (const command of commandsToAck) { + this.repositoryService.commandRepository.markAsAcknowledged(command.id); + } + this.logger.trace(`${commandsToAck.length} commands acknowledged`); + } catch (fetchError) { + this.logger.error(`Error while acknowledging ${commandsToAck.length} commands on ${url}. ${fetchError}`); + } + } + + /** + * Check if retrieved commands have been cancelled on OIAnalytics before running them + */ + async checkRetrievedCommands(): Promise<void> { + const pendingCommands = this.repositoryService.commandRepository.searchCommandsList({ status: ['RETRIEVED'], types: [] }); + if (pendingCommands.length === 0) { + return; + } + + let endpoint = `/api/oianalytics/oibus/commands/list-by-ids?`; + for (const command of pendingCommands) { + endpoint += `ids=${command.id}&`; + } + endpoint = endpoint.slice(0, endpoint.length - 1); + const registrationSettings = this.getRegistrationSettings(); + const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); + let response; + const url = `${connectionSettings.host}${endpoint}`; + try { + response = await fetch(url, { + method: 'GET', + headers: connectionSettings.headers, + timeout: CHECK_TIMEOUT, + agent: connectionSettings.agent + }); + if (!response.ok) { + this.logger.error(`Error ${response.status} while checking PENDING commands status on ${url}: ${response.statusText}`); + return; + } + const commandsToCancel: Array<OIBusCommandDTO> = await response.json(); + if (commandsToCancel.length === 0) { + this.logger.trace(`No command cancelled among the ${pendingCommands.length} commands`); + return; + } + this.logger.trace(`${commandsToCancel.length} commands cancelled among the ${pendingCommands.length} pending commands`); + for (const command of commandsToCancel) { + this.commandService.removeCommandFromQueue(command.id); + this.repositoryService.commandRepository.cancel(command.id); + } + } catch (fetchError) { + this.logger.error(`Error while checking PENDING commands status on ${url}. ${fetchError}`); + } + } + + async retrieveCommands(): Promise<void> { + const endpoint = `/api/oianalytics/oibus/commands/pending`; + const registrationSettings = this.getRegistrationSettings(); + const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); + let response; + const url = `${connectionSettings.host}${endpoint}`; + try { + response = await fetch(url, { + method: 'GET', + headers: connectionSettings.headers, + timeout: CHECK_TIMEOUT, + agent: connectionSettings.agent + }); + if (!response.ok) { + this.logger.error(`Error ${response.status} while retrieving commands on ${url}: ${response.statusText}`); + return; + } + const newCommands: Array<OIBusCommandDTO> = await response.json(); + if (newCommands.length === 0) { + this.logger.trace(`No command to create`); + return; + } + this.logger.trace(`${newCommands.length} commands to add`); + for (const command of newCommands) { + const creationCommand: OIBusCommand = { + type: command.type, + version: command.version, + assetId: command.assetId + }; + const newCommand = this.repositoryService.commandRepository.create(command.id, creationCommand); + this.commandService.addCommandToQueue(newCommand); + } + await this.sendAckCommands(); + } catch (fetchError) { + this.logger.error(`Error while retrieving commands on ${url}. ${fetchError}`); + } + } + + stop() { + if (this.intervalCheckRegistration) { + clearInterval(this.intervalCheckRegistration); + this.intervalCheckRegistration = null; + } + + if (this.intervalCheckCommands) { + clearInterval(this.intervalCheckCommands); + this.intervalCheckCommands = null; + } + } + + async onUnregister() { + this.unregister(); + const engineSettings = this.repositoryService.engineRepository.getEngineSettings()!; + if (engineSettings.logParameters.oia.level !== 'silent') { + await this.reloadService.restartLogger(engineSettings); + } + } +} diff --git a/backend/src/service/oibus.service.spec.ts b/backend/src/service/oibus.service.spec.ts index 3ac434666e..c3575d932c 100644 --- a/backend/src/service/oibus.service.spec.ts +++ b/backend/src/service/oibus.service.spec.ts @@ -1,50 +1,22 @@ -import fetch from 'node-fetch'; import OIBusService from './oibus.service'; import OIBusEngine from '../engine/oibus-engine'; import OibusEngineMock from '../tests/__mocks__/oibus-engine.mock'; import HistoryQueryEngine from '../engine/history-query-engine'; import HistoryQueryEngineMock from '../tests/__mocks__/history-query-engine.mock'; -import * as utils from '../service/utils'; -import RepositoryService from './repository.service'; -import RepositoryServiceMock from '../tests/__mocks__/repository-service.mock'; -import { RegistrationSettingsCommandDTO, RegistrationSettingsDTO } from '../../../shared/model/engine.model'; -import EncryptionServiceMock from '../tests/__mocks__/encryption-service.mock'; -import EncryptionService from './encryption.service'; +import { createProxyAgent } from './proxy-agent'; import pino from 'pino'; import PinoLogger from '../tests/__mocks__/logger.mock'; -import { createProxyAgent } from './proxy-agent'; -import { OIBusCommandDTO } from '../../../shared/model/command.model'; -import { getNetworkSettingsFromRegistration } from './utils'; -import CommandService from './command.service'; -import CommandServiceMock from '../tests/__mocks__/command-service.mock'; jest.mock('node:fs/promises'); jest.mock('node-fetch'); -const { Response } = jest.requireActual('node-fetch'); jest.mock('./utils'); jest.mock('./proxy-agent'); const oibusEngine: OIBusEngine = new OibusEngineMock(); const historyQueryEngine: HistoryQueryEngine = new HistoryQueryEngineMock(); -const repositoryService: RepositoryService = new RepositoryServiceMock('', ''); -const encryptionService: EncryptionService = new EncryptionServiceMock('', ''); -const commandService: CommandService = new CommandServiceMock('', ''); - -const nowDateString = '2020-02-02T02:02:02.222Z'; const logger: pino.Logger = new PinoLogger(); -const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate); -const command: OIBusCommandDTO = { - id: 'id1', - type: 'UPGRADE', - status: 'COMPLETED', - ack: true, - creationDate: '2023-01-01T12:00:00Z', - completedDate: '2023-01-01T12:00:00Z', - result: 'ok', - version: '3.2.0', - assetId: 'assetId' -}; +const nowDateString = '2020-02-02T02:02:02.222Z'; let service: OIBusService; describe('OIBus service', () => { @@ -53,7 +25,7 @@ describe('OIBus service', () => { jest.useFakeTimers().setSystemTime(new Date(nowDateString)); (createProxyAgent as jest.Mock).mockReturnValue(undefined); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); + service = new OIBusService(oibusEngine, historyQueryEngine); }); it('should restart OIBus', async () => { @@ -80,730 +52,9 @@ describe('OIBus service', () => { expect(oibusEngine.addExternalFile).toHaveBeenCalledWith('source', 'filePath'); }); - it('should get NOT_REGISTERED registration settings', () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - status: 'NOT_REGISTERED', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const result = service.getRegistrationSettings(); - expect(result).toEqual(mockResult); - expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); - }); - - it('should update registration', async () => { - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false - }; - (utils.getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - const fetchResponse = { - redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - expirationDate: '2020-02-02T02:12:02.222Z' - }; - - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.updateRegistrationSettings(command); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); - expect(utils.generateRandomId).toHaveBeenCalledWith(6); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( - command, - '1234', - 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - '2020-02-02T02:12:02.222Z' - ); - }); - - it('should update registration with proxy', async () => { - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: true, - proxyUrl: 'http://localhost:3128', - proxyUsername: 'user', - proxyPassword: 'pass' - }; - (utils.getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - const fetchResponse = { - redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - expirationDate: '2020-02-02T02:12:02.222Z' - }; - - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.updateRegistrationSettings(command); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); - expect(utils.generateRandomId).toHaveBeenCalledWith(6); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( - command, - '1234', - 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - '2020-02-02T02:12:02.222Z' - ); - }); - - it('should update registration with proxy and without password', async () => { - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: true, - proxyUrl: 'http://localhost:3128', - proxyUsername: '', - proxyPassword: '' - }; - (utils.getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - const fetchResponse = { - redirectUrl: 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - expirationDate: '2020-02-02T02:12:02.222Z' - }; - - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.updateRegistrationSettings(command); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledTimes(1); - expect(utils.generateRandomId).toHaveBeenCalledWith(6); - expect(repositoryService.registrationRepository.updateRegistration).toHaveBeenCalledWith( - command, - '1234', - 'http://localhost:4200/api/oianalytics/oibus/check-registration?id=id', - '2020-02-02T02:12:02.222Z' - ); - }); - - it('should handle fetch error during registration update', async () => { - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false - }; - (utils.getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - (fetch as unknown as jest.Mock).mockImplementation(() => { - throw new Error('error'); - }); - - let error; - try { - await service.updateRegistrationSettings(command); - } catch (e) { - error = e; - } - expect(error).toEqual(new Error('Registration failed: Error: error')); - }); - - it('should handle fetch bad response during registration update', async () => { - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false - }; - (utils.getOIBusInfo as jest.Mock).mockReturnValueOnce({ version: 'v3.2.0' }); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); - - let error; - try { - await service.updateRegistrationSettings(command); - } catch (e) { - error = e; - } - expect(error).toEqual(new Error(`Registration failed with status code 404 and message: Not Found`)); - }); - - it('should handle error if registration not found', async () => { - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValueOnce(null); - (utils.generateRandomId as jest.Mock).mockReturnValue('1234'); - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - - const command: RegistrationSettingsCommandDTO = { - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false - }; - - let error; - try { - await service.updateRegistrationSettings(command); - } catch (e) { - error = e; - } - expect(error).toEqual(new Error('Registration settings not found')); - }); - - it('should activate registration', async () => { - await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); - expect(repositoryService.registrationRepository.activateRegistration).toHaveBeenCalledWith('2020-20-20T00:00:00.000Z', 'token'); - }); - - it('should unregister', () => { - service.unregister(); - expect(repositoryService.registrationRepository.unregister).toHaveBeenCalledTimes(1); - }); - - it('should check registration', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - service.activateRegistration = jest.fn(); - - await service.checkRegistration(); - expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); - expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); - }); - - it('should check registration and return because already checking', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - service.activateRegistration = jest.fn(); - - service.checkRegistration(); - await service.checkRegistration(); - expect(logger.trace).toHaveBeenCalledWith('On going registration check'); - await flushPromises(); - }); - - it('should check registration but fail because of return status', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const fetchResponse = { status: 'DECLINED', expired: true, accessToken: 'access_token' }; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - service.activateRegistration = jest.fn(); - - await service.checkRegistration(); - expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); - expect(service.activateRegistration).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith(`Registration not completed. Status: DECLINED`); - await service.checkRegistration(); - }); - - it('should check registration but fail because of fetch response', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); - await service.checkRegistration(); - expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); - expect(logger.error).toHaveBeenCalledWith( - `Error 404 while checking registration status on ${mockResult.host}${mockResult.checkUrl}: Not Found` - ); - }); - - it('should check registration and fail when registration check url not set', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - await service.checkRegistration(); - expect(logger.error).toHaveBeenCalledWith('Error while checking registration status: Could not retrieve check URL'); - }); - - it('should check registration and fail on fetch error', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - checkUrl: 'check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - (fetch as unknown as jest.Mock).mockImplementation(() => { - throw new Error('error'); - }); - await service.checkRegistration(); - expect(logger.error).toHaveBeenCalledWith( - `Error while checking registration status on ${mockResult.host}${mockResult.checkUrl}. Error: error` - ); - }); - - it('should check registration with proxy', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: true, - proxyUrl: 'http://localhost:3128', - proxyUsername: 'user', - proxyPassword: 'pass', - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - service.activateRegistration = jest.fn(); - - await service.checkRegistration(); - expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); - expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); - }); - - it('should check registration with proxy and without password', async () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: true, - proxyUrl: 'http://localhost:3128', - proxyUsername: '', - proxyPassword: '', - token: 'token', - activationCode: '1234', - checkUrl: '/check/url', - status: 'PENDING', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - const fetchResponse = { status: 'COMPLETED', expired: true, accessToken: 'access_token' }; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - service.activateRegistration = jest.fn(); - - await service.checkRegistration(); - expect(fetch).toHaveBeenCalledWith(`${mockResult.host}${mockResult.checkUrl}`, { method: 'GET', timeout: 10000 }); - expect(service.activateRegistration).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', 'access_token'); - }); - - it('should check commands', async () => { - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - - service.checkRetrievedCommands = jest.fn(); - service.retrieveCommands = jest.fn(); - service.sendAckCommands = jest.fn(); - - await service.checkCommands(); - expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); - expect(service.retrieveCommands).toHaveBeenCalledTimes(1); - expect(service.sendAckCommands).toHaveBeenCalledTimes(1); - expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); - expect(service.retrieveCommands).toHaveBeenCalledTimes(1); - expect(service.sendAckCommands).toHaveBeenCalledTimes(1); - }); - - it('should check comm ands and return because already checking', async () => { - (repositoryService.engineRepository.getEngineSettings as jest.Mock).mockReturnValueOnce({ id: 'id1', name: 'MyOIBus' }); - - service.checkRetrievedCommands = jest.fn().mockImplementation(() => { - return new Promise<void>(resolve => { - setTimeout(resolve, 1000); - }); - }); - service.retrieveCommands = jest.fn(); - service.sendAckCommands = jest.fn(); - - service.checkCommands(); - await service.checkCommands(); - expect(service.checkRetrievedCommands).toHaveBeenCalledTimes(1); - expect(service.retrieveCommands).not.toHaveBeenCalled(); - expect(logger.trace).toHaveBeenCalledWith('On going commands check'); - await flushPromises(); - }); -}); - -describe('OIBus service with PENDING registration', () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - status: 'PENDING', - checkUrl: 'http://localhost:4200/check/url', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers().setSystemTime(new Date(nowDateString)); - (createProxyAgent as jest.Mock).mockReturnValue(undefined); - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - }); - - it('should get PENDING registration settings', () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - - const result = service.getRegistrationSettings(); - expect(result).toEqual(mockResult); - expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); - }); - - it('should stop and clear interval', async () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - await service.stopOIBus(); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); - - it('should activate registration and clear interval', async () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); - - it('should unregister and clear interval', () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - service.unregister(); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe('OIBus service with REGISTERED registration', () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - status: 'REGISTERED', - checkUrl: 'http://localhost:4200/check/url', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers().setSystemTime(new Date(nowDateString)); - (createProxyAgent as jest.Mock).mockReturnValue(undefined); - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - }); - - it('should get REGISTERED registration settings', () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - - const result = service.getRegistrationSettings(); - expect(result).toEqual(mockResult); - expect(repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalledTimes(2); - }); - - it('should stop and clear interval', async () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - await service.stopOIBus(); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); - - it('should activate registration and clear interval', async () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - await service.activateRegistration('2020-20-20T00:00:00.000Z', 'token'); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); - - it('should unregister and clear interval', () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - expect(setIntervalSpy).toHaveBeenCalledTimes(1); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - service.unregister(); - expect(clearIntervalSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe('OIBus service should interact with OIA and', () => { - const mockResult: RegistrationSettingsDTO = { - id: 'id', - host: 'http://localhost:4200', - acceptUnauthorized: false, - useProxy: false, - token: 'token', - activationCode: '1234', - status: 'REGISTERED', - checkUrl: 'http://localhost:4200/check/url', - activationDate: '2020-20-20T00:00:00.000Z', - activationExpirationDate: '' - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers().setSystemTime(new Date(nowDateString)); - (createProxyAgent as jest.Mock).mockReturnValue(undefined); - (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue(mockResult); - service = new OIBusService(oibusEngine, historyQueryEngine, repositoryService, encryptionService, commandService, logger); - (getNetworkSettingsFromRegistration as jest.Mock).mockReturnValue({ host: 'http://localhost:4200', headers: {}, agent: undefined }); - }); - - it('should ack commands and return if no commands in OIBus', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([]); - - await service.sendAckCommands(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it('should ack commands', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - const fetchResponse: Array<OIBusCommandDTO> = [{ id: 'id1' }] as Array<OIBusCommandDTO>; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.sendAckCommands(); - expect(logger.trace).toHaveBeenCalledWith(`1 commands acknowledged`); - expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/status', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify([command]), - timeout: 10000, - agent: undefined - }); - expect(repositoryService.commandRepository.markAsAcknowledged).toHaveBeenCalledWith('id1'); - }); - - it('should ack commands and manage 404 error', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); - - await service.sendAckCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error 404 while acknowledging 1 commands on http://localhost:4200/api/oianalytics/oibus/commands/status: Not Found` - ); - }); - - it('should ack commands and log error on fetch error', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - (fetch as unknown as jest.Mock).mockImplementation(() => { - throw new Error('error'); - }); - - await service.sendAckCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error while acknowledging 1 commands on http://localhost:4200/api/oianalytics/oibus/commands/status. ${new Error('error')}` - ); - }); - - it('should check cancelled commands and return if no commands in OIBus', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([]); - - await service.checkRetrievedCommands(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it('should check cancelled commands and no command retrieved from oia', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - const fetchResponse: Array<OIBusCommandDTO> = []; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.checkRetrievedCommands(); - expect(logger.trace).toHaveBeenCalledWith(`No command cancelled among the 1 commands`); - expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1', { - method: 'GET', - headers: {}, - timeout: 10000, - agent: undefined - }); - }); - - it('should check cancelled commands and cancel retrieved commands', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - const fetchResponse: Array<OIBusCommandDTO> = [{ id: 'id1' }] as Array<OIBusCommandDTO>; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.checkRetrievedCommands(); - expect(logger.trace).toHaveBeenCalledWith(`1 commands cancelled among the 1 pending commands`); - expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1', { - method: 'GET', - headers: {}, - timeout: 10000, - agent: undefined - }); - expect(repositoryService.commandRepository.cancel).toHaveBeenCalledWith('id1'); - }); - - it('should check cancelled commands log error if fetch response not ok', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); - - await service.checkRetrievedCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error 404 while checking PENDING commands status on http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1: Not Found` - ); - }); - - it('should check cancelled commands log error on fetch error', async () => { - (repositoryService.commandRepository.searchCommandsList as jest.Mock).mockReturnValue([command]); - - (fetch as unknown as jest.Mock).mockImplementation(() => { - throw new Error('error'); - }); - - await service.checkRetrievedCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error while checking PENDING commands status on http://localhost:4200/api/oianalytics/oibus/commands/list-by-ids?ids=id1. ${new Error( - 'error' - )}` - ); - }); - - it('should retrieve commands and trace logs if no command retrieved', async () => { - const fetchResponse: Array<OIBusCommandDTO> = []; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.retrieveCommands(); - expect(logger.trace).toHaveBeenCalledWith(`No command to create`); - expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/pending', { - method: 'GET', - headers: {}, - timeout: 10000, - agent: undefined - }); - }); - - it('should retrieve and create commands', async () => { - const fetchResponse: Array<OIBusCommandDTO> = [command]; - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response(JSON.stringify(fetchResponse)))); - - await service.retrieveCommands(); - expect(logger.trace).toHaveBeenCalledWith(`1 commands to add`); - expect(fetch).toHaveBeenCalledWith('http://localhost:4200/api/oianalytics/oibus/commands/pending', { - method: 'GET', - headers: {}, - timeout: 10000, - agent: undefined - }); - expect(repositoryService.commandRepository.create).toHaveBeenCalledWith('id1', { - type: command.type, - version: command.version, - assetId: command.assetId - }); - }); - - it('should retrieve log error on bad fetch response', async () => { - (fetch as unknown as jest.Mock).mockReturnValueOnce(Promise.resolve(new Response('invalid', { status: 404 }))); - - await service.retrieveCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error 404 while retrieving commands on http://localhost:4200/api/oianalytics/oibus/commands/pending: Not Found` - ); - }); - - it('should retrieve log error on fetch error', async () => { - (fetch as unknown as jest.Mock).mockImplementation(() => { - throw new Error('error'); - }); - - await service.retrieveCommands(); - expect(logger.error).toHaveBeenCalledWith( - `Error while retrieving commands on http://localhost:4200/api/oianalytics/oibus/commands/pending. ${new Error('error')}` - ); + it('should set logger', () => { + service.setLogger(logger); + expect(oibusEngine.setLogger).toHaveBeenCalledWith(logger); + expect(historyQueryEngine.setLogger).toHaveBeenCalledWith(logger); }); }); diff --git a/backend/src/service/oibus.service.ts b/backend/src/service/oibus.service.ts index f3c59c8884..39717226a2 100644 --- a/backend/src/service/oibus.service.ts +++ b/backend/src/service/oibus.service.ts @@ -1,41 +1,12 @@ -import fetch from 'node-fetch'; import OIBusEngine from '../engine/oibus-engine'; import HistoryQueryEngine from '../engine/history-query-engine'; -import { generateRandomId, getNetworkSettingsFromRegistration, getOIBusInfo } from './utils'; -import RepositoryService from './repository.service'; -import { RegistrationSettingsCommandDTO, RegistrationSettingsDTO } from '../../../shared/model/engine.model'; -import EncryptionService from './encryption.service'; import pino from 'pino'; -import { Instant } from '../../../shared/model/types'; -import { DateTime } from 'luxon'; -import { createProxyAgent } from './proxy-agent'; -import { OIBusCommand, OIBusCommandDTO } from '../../../shared/model/command.model'; -import CommandService from './command.service'; - -const CHECK_TIMEOUT = 10_000; export default class OIBusService { - private intervalCheckRegistration: NodeJS.Timeout | null = null; - private intervalCheckCommands: NodeJS.Timeout | null = null; - private ongoingCheckRegistration = false; - private ongoingCheckCommands = false; - constructor( private engine: OIBusEngine, - private historyEngine: HistoryQueryEngine, - private repositoryService: RepositoryService, - private encryptionService: EncryptionService, - private commandService: CommandService, - private logger: pino.Logger - ) { - const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings(); - if (registrationSettings && registrationSettings.checkUrl && registrationSettings.status === 'PENDING') { - this.intervalCheckRegistration = setInterval(this.checkRegistration.bind(this), CHECK_TIMEOUT); - } - if (registrationSettings && registrationSettings.status === 'REGISTERED') { - this.intervalCheckCommands = setInterval(this.checkCommands.bind(this), CHECK_TIMEOUT); - } - } + private historyEngine: HistoryQueryEngine + ) {} async restartOIBus(): Promise<void> { await this.engine.stop(); @@ -47,15 +18,6 @@ export default class OIBusService { async stopOIBus(): Promise<void> { await this.engine.stop(); await this.historyEngine.stop(); - if (this.intervalCheckRegistration) { - clearInterval(this.intervalCheckRegistration); - this.intervalCheckRegistration = null; - } - - if (this.intervalCheckCommands) { - clearInterval(this.intervalCheckCommands); - this.intervalCheckCommands = null; - } } async addValues(externalSourceId: string | null, values: Array<any>): Promise<void> { @@ -66,284 +28,8 @@ export default class OIBusService { await this.engine.addExternalFile(externalSourceId, filePath); } - getRegistrationSettings(): RegistrationSettingsDTO | null { - return this.repositoryService.registrationRepository.getRegistrationSettings(); - } - - async updateRegistrationSettings(command: RegistrationSettingsCommandDTO): Promise<void> { - const activationCode = generateRandomId(6); - const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings()!; - if (!registrationSettings) { - throw new Error(`Registration settings not found`); - } - - if (!command.proxyPassword) { - command.proxyPassword = registrationSettings.proxyPassword; - } else { - command.proxyPassword = await this.encryptionService.encryptText(command.proxyPassword); - } - - const engineSettings = this.repositoryService.engineRepository.getEngineSettings()!; - - const oibusInfo = getOIBusInfo(); - const body = { - activationCode, - oibusVersion: oibusInfo.version, - oibusArch: oibusInfo.architecture, - oibusOs: oibusInfo.operatingSystem, - oibusId: engineSettings.id, - oibusName: engineSettings.name - }; - let response; - try { - const url = `${command.host}/api/oianalytics/oibus/registration`; - const agent = createProxyAgent( - command.useProxy, - url, - command.useProxy - ? { - url: command.proxyUrl!, - username: command.proxyUsername!, - password: command.proxyPassword ? await this.encryptionService.decryptText(command.proxyPassword) : null - } - : null, - command.acceptUnauthorized - ); - response = await fetch(url, { - method: 'POST', - timeout: CHECK_TIMEOUT, - agent, - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (fetchError) { - throw new Error(`Registration failed: ${fetchError}`); - } - - if (!response.ok) { - throw new Error(`Registration failed with status code ${response.status} and message: ${response.statusText}`); - } - - const result: { redirectUrl: string; expirationDate: Instant } = await response.json(); - this.repositoryService.registrationRepository.updateRegistration(command, activationCode, result.redirectUrl, result.expirationDate); - if (!this.intervalCheckRegistration) { - this.intervalCheckRegistration = setInterval(this.checkRegistration.bind(this), CHECK_TIMEOUT); - } - } - - async activateRegistration(activationDate: string, accessToken: string): Promise<void> { - const encryptedToken = await this.encryptionService.encryptText(accessToken); - this.repositoryService.registrationRepository.activateRegistration(activationDate, encryptedToken); - if (this.intervalCheckRegistration) { - clearInterval(this.intervalCheckRegistration); - this.intervalCheckRegistration = null; - } - - if (this.intervalCheckCommands) { - clearInterval(this.intervalCheckCommands); - this.intervalCheckCommands = null; - } - this.intervalCheckCommands = setInterval(this.checkCommands.bind(this), CHECK_TIMEOUT); - } - - unregister() { - this.repositoryService.registrationRepository.unregister(); - if (this.intervalCheckRegistration) { - clearInterval(this.intervalCheckRegistration); - this.intervalCheckRegistration = null; - } - - if (this.intervalCheckCommands) { - clearInterval(this.intervalCheckCommands); - this.intervalCheckCommands = null; - } - } - - async checkRegistration(): Promise<void> { - if (this.ongoingCheckRegistration) { - this.logger.trace(`On going registration check`); - return; - } - this.logger.trace(`Registration check`); - const registrationSettings = this.repositoryService.registrationRepository.getRegistrationSettings(); - if (!registrationSettings || !registrationSettings.checkUrl) { - this.logger.error(`Error while checking registration status: Could not retrieve check URL`); - return; - } - let response; - const url = `${registrationSettings.host}${registrationSettings.checkUrl}`; - try { - this.ongoingCheckRegistration = true; - const agent = createProxyAgent( - registrationSettings.useProxy, - url, - registrationSettings.useProxy - ? { - url: registrationSettings.proxyUrl!, - username: registrationSettings.proxyUsername!, - password: registrationSettings.proxyPassword - ? await this.encryptionService.decryptText(registrationSettings.proxyPassword) - : null - } - : null, - registrationSettings.acceptUnauthorized - ); - response = await fetch(url, { - method: 'GET', - timeout: CHECK_TIMEOUT, - agent - }); - if (!response.ok) { - this.logger.error(`Error ${response.status} while checking registration status on ${url}: ${response.statusText}`); - this.ongoingCheckRegistration = false; - return; - } - - const responseData: { status: string; expired: boolean; accessToken: string } = await response.json(); - if (responseData.status !== 'COMPLETED') { - this.logger.warn(`Registration not completed. Status: ${responseData.status}`); - } else { - await this.activateRegistration(DateTime.now().toUTC().toISO()!, responseData.accessToken); - this.logger.info(`OIBus registered on ${registrationSettings.host}`); - } - } catch (fetchError) { - this.logger.error(`Error while checking registration status on ${url}. ${fetchError}`); - } - this.ongoingCheckRegistration = false; - } - - async checkCommands(): Promise<void> { - if (this.ongoingCheckCommands) { - this.logger.trace(`On going commands check`); - return; - } - this.ongoingCheckCommands = true; - - await this.sendAckCommands(); - await this.checkRetrievedCommands(); - await this.retrieveCommands(); - this.ongoingCheckCommands = false; - } - - async sendAckCommands(): Promise<void> { - const commandsToAck = this.repositoryService.commandRepository.searchCommandsList({ - status: [], - types: [], - ack: false - }); - if (commandsToAck.length === 0) { - return; - } - - const endpoint = `/api/oianalytics/oibus/commands/status`; - const registrationSettings = this.getRegistrationSettings(); - const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); - let response; - const url = `${connectionSettings.host}${endpoint}`; - try { - response = await fetch(url, { - method: 'PUT', - body: JSON.stringify(commandsToAck), - headers: { ...connectionSettings.headers, 'Content-Type': 'application/json' }, - timeout: CHECK_TIMEOUT, - agent: connectionSettings.agent - }); - if (!response.ok) { - this.logger.error( - `Error ${response.status} while acknowledging ${commandsToAck.length} commands on ${url}: ${response.statusText}` - ); - return; - } - for (const command of commandsToAck) { - this.repositoryService.commandRepository.markAsAcknowledged(command.id); - } - this.logger.trace(`${commandsToAck.length} commands acknowledged`); - } catch (fetchError) { - this.logger.error(`Error while acknowledging ${commandsToAck.length} commands on ${url}. ${fetchError}`); - } - } - - /** - * Check if retrieved commands have been cancelled on OIAnalytics before running them - */ - async checkRetrievedCommands(): Promise<void> { - const pendingCommands = this.repositoryService.commandRepository.searchCommandsList({ status: ['RETRIEVED'], types: [] }); - if (pendingCommands.length === 0) { - return; - } - - let endpoint = `/api/oianalytics/oibus/commands/list-by-ids?`; - for (const command of pendingCommands) { - endpoint += `ids=${command.id}&`; - } - endpoint = endpoint.slice(0, endpoint.length - 1); - const registrationSettings = this.getRegistrationSettings(); - const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); - let response; - const url = `${connectionSettings.host}${endpoint}`; - try { - response = await fetch(url, { - method: 'GET', - headers: connectionSettings.headers, - timeout: CHECK_TIMEOUT, - agent: connectionSettings.agent - }); - if (!response.ok) { - this.logger.error(`Error ${response.status} while checking PENDING commands status on ${url}: ${response.statusText}`); - return; - } - const commandsToCancel: Array<OIBusCommandDTO> = await response.json(); - if (commandsToCancel.length === 0) { - this.logger.trace(`No command cancelled among the ${pendingCommands.length} commands`); - return; - } - this.logger.trace(`${commandsToCancel.length} commands cancelled among the ${pendingCommands.length} pending commands`); - for (const command of commandsToCancel) { - this.commandService.removeCommandFromQueue(command.id); - this.repositoryService.commandRepository.cancel(command.id); - } - } catch (fetchError) { - this.logger.error(`Error while checking PENDING commands status on ${url}. ${fetchError}`); - } - } - - async retrieveCommands(): Promise<void> { - const endpoint = `/api/oianalytics/oibus/commands/pending`; - const registrationSettings = this.getRegistrationSettings(); - const connectionSettings = await getNetworkSettingsFromRegistration(registrationSettings, endpoint, this.encryptionService); - let response; - const url = `${connectionSettings.host}${endpoint}`; - try { - response = await fetch(url, { - method: 'GET', - headers: connectionSettings.headers, - timeout: CHECK_TIMEOUT, - agent: connectionSettings.agent - }); - if (!response.ok) { - this.logger.error(`Error ${response.status} while retrieving commands on ${url}: ${response.statusText}`); - return; - } - const newCommands: Array<OIBusCommandDTO> = await response.json(); - if (newCommands.length === 0) { - this.logger.trace(`No command to create`); - return; - } - this.logger.trace(`${newCommands.length} commands to add`); - for (const command of newCommands) { - const creationCommand: OIBusCommand = { - type: command.type, - version: command.version, - assetId: command.assetId - }; - const newCommand = this.repositoryService.commandRepository.create(command.id, creationCommand); - this.commandService.addCommandToQueue(newCommand); - } - await this.sendAckCommands(); - } catch (fetchError) { - this.logger.error(`Error while retrieving commands on ${url}. ${fetchError}`); - } + setLogger(logger: pino.Logger) { + this.engine.setLogger(logger); + this.historyEngine.setLogger(logger); } } diff --git a/backend/src/service/reload.service.spec.ts b/backend/src/service/reload.service.spec.ts index 7acd01fb35..17b004612b 100644 --- a/backend/src/service/reload.service.spec.ts +++ b/backend/src/service/reload.service.spec.ts @@ -13,7 +13,7 @@ import LoggerService from './logger/logger.service'; import EngineMetricsService from './engine-metrics.service'; import NorthService from './north.service'; import OIBusEngine from '../engine/oibus-engine'; -import { EngineSettingsDTO, LogSettings } from '../../../shared/model/engine.model'; +import { EngineSettingsDTO, LogSettings, RegistrationSettingsDTO } from '../../../shared/model/engine.model'; import { SouthConnectorItemCommandDTO, SouthConnectorItemDTO, @@ -29,6 +29,8 @@ import HomeMetricsService from './home-metrics.service'; import HomeMetricsServiceMock from '../tests/__mocks__/home-metrics-service.mock'; import ProxyServer from '../web-server/proxy-server'; import ProxyServerMock from '../tests/__mocks__/proxy-server.mock'; +import OIBusService from './oibus.service'; +import OibusServiceMock from '../tests/__mocks__/oibus-service.mock'; jest.mock('./encryption.service'); jest.mock('./logger/logger.service'); @@ -43,12 +45,16 @@ const engineMetricsService: EngineMetricsService = new EngineMetricsServiceMock( const homeMetrics: HomeMetricsService = new HomeMetricsServiceMock(); const northService: NorthService = new NorthServiceMock(); const southService: SouthService = new SouthServiceMock(); +const oibusService: OIBusService = new OibusServiceMock(); const loggerService: LoggerService = new LoggerService(encryptionService, 'folder'); let service: ReloadService; describe('reload service', () => { beforeEach(() => { jest.clearAllMocks(); + (repositoryService.registrationRepository.getRegistrationSettings as jest.Mock).mockReturnValue({ + id: 'id1' + } as RegistrationSettingsDTO); service = new ReloadService( loggerService, repositoryService, @@ -58,6 +64,7 @@ describe('reload service', () => { southService, oibusEngine, historyQueryEngine, + oibusService, proxyServer ); }); @@ -70,6 +77,8 @@ describe('reload service', () => { expect(service.northService).toBeDefined(); expect(service.southService).toBeDefined(); expect(service.oibusEngine).toBeDefined(); + expect(service.oibusService).toBeDefined(); + expect(service.proxyServer).toBeDefined(); }); it('should update port', async () => { @@ -100,7 +109,7 @@ describe('reload service', () => { service.setWebServerChangeLogger(changeLoggerFn); await service.onUpdateOibusSettings(null, newSettings as EngineSettingsDTO); expect(loggerService.stop).toHaveBeenCalledTimes(1); - expect(loggerService.start).toHaveBeenCalledWith(newSettings.id, newSettings.name, newSettings.logParameters); + expect(loggerService.start).toHaveBeenCalledWith(newSettings.id, newSettings.name, newSettings.logParameters, { id: 'id1' }); expect(changeLoggerFn).toHaveBeenCalledTimes(1); expect(engineMetricsService.setLogger).toHaveBeenCalledTimes(1); }); diff --git a/backend/src/service/reload.service.ts b/backend/src/service/reload.service.ts index 680a41f8dc..dd02e31852 100644 --- a/backend/src/service/reload.service.ts +++ b/backend/src/service/reload.service.ts @@ -20,6 +20,7 @@ import { Instant } from '../../../shared/model/types'; import { ScanModeCommandDTO } from '../../../shared/model/scan-mode.model'; import HomeMetricsService from './home-metrics.service'; import ProxyServer from '../web-server/proxy-server'; +import OIBusService from './oibus.service'; export default class ReloadService { private webServerChangeLoggerCallback: (logger: pino.Logger) => void = () => {}; @@ -34,6 +35,7 @@ export default class ReloadService { private readonly _southService: SouthService, private readonly _oibusEngine: OIBusEngine, private readonly _historyEngine: HistoryQueryEngine, + private readonly _oibusService: OIBusService, private readonly _proxyServer: ProxyServer ) {} @@ -69,6 +71,10 @@ export default class ReloadService { return this._historyEngine; } + get oibusService(): OIBusService { + return this._oibusService; + } + get proxyServer(): ProxyServer { return this._proxyServer; } @@ -87,12 +93,7 @@ export default class ReloadService { JSON.stringify(oldSettings.logParameters) !== JSON.stringify(newSettings.logParameters) || oldSettings.name !== newSettings.name ) { - this.loggerService.stop(); - await this.loggerService.start(newSettings.id, newSettings.name, newSettings.logParameters); - this.webServerChangeLoggerCallback(this.loggerService.createChildLogger('web-server')); - this.engineMetricsService.setLogger(this.loggerService.createChildLogger('internal')); - this.oibusEngine.setLogger(this.loggerService.createChildLogger('internal')); - this.proxyServer.setLogger(this.loggerService.createChildLogger('internal')); + await this.restartLogger(newSettings); } if (!oldSettings || oldSettings.port !== newSettings.port) { await this.webServerChangePortCallback(newSettings.port); @@ -105,6 +106,16 @@ export default class ReloadService { } } + public async restartLogger(newSettings: EngineSettingsDTO) { + this.loggerService.stop(); + const registration = this.repositoryService.registrationRepository.getRegistrationSettings()!; + await this.loggerService.start(newSettings.id, newSettings.name, newSettings.logParameters, registration); + this.webServerChangeLoggerCallback(this.loggerService.createChildLogger('web-server')); + this.oibusService.setLogger(this.loggerService.createChildLogger('internal')); + this.engineMetricsService.setLogger(this.loggerService.createChildLogger('internal')); + this.proxyServer.setLogger(this.loggerService.createChildLogger('internal')); + } + async onCreateSouth(command: SouthConnectorCommandDTO): Promise<SouthConnectorDTO> { const southConnector = this.repositoryService.southConnectorRepository.createSouthConnector(command); if (command.enabled) { diff --git a/backend/src/tests/__mocks__/koa-context.mock.ts b/backend/src/tests/__mocks__/koa-context.mock.ts index 04ba0017f1..6a528a584f 100644 --- a/backend/src/tests/__mocks__/koa-context.mock.ts +++ b/backend/src/tests/__mocks__/koa-context.mock.ts @@ -5,6 +5,7 @@ import NorthServiceMock from './north-service.mock'; import SouthServiceMock from './south-service.mock'; import OIBusServiceMock from './oibus-service.mock'; import EngineMetricsServiceMock from './engine-metrics-service.mock'; +import RegistrationServiceMock from './registration-service.mock'; /** * Create a mock object for Koa Context @@ -17,6 +18,7 @@ export default jest.fn().mockImplementation(() => ({ northService: new NorthServiceMock(), southService: new SouthServiceMock(), oibusService: new OIBusServiceMock(), + registrationService: new RegistrationServiceMock(), engineMetricsService: new EngineMetricsServiceMock(), logger: { trace: jest.fn(), diff --git a/backend/src/tests/__mocks__/oibus-service.mock.ts b/backend/src/tests/__mocks__/oibus-service.mock.ts index 4dbd60e95f..120e719bad 100644 --- a/backend/src/tests/__mocks__/oibus-service.mock.ts +++ b/backend/src/tests/__mocks__/oibus-service.mock.ts @@ -2,14 +2,9 @@ * Create a mock object for OIBus Service */ export default jest.fn().mockImplementation(() => ({ - getOIBusInfo: jest.fn(), - stopOIBus: jest.fn(), - restartOIBus: jest.fn(), - addValues: jest.fn(), + setLogger: jest.fn(), addFile: jest.fn(), - checkForUpdate: jest.fn(), - downloadUpdate: jest.fn(), - getRegistrationSettings: jest.fn(), - updateRegistrationSettings: jest.fn(), - unregister: jest.fn() + addValues: jest.fn(), + stopOIBus: jest.fn(), + restartOIBus: jest.fn() })); diff --git a/backend/src/tests/__mocks__/registration-service.mock.ts b/backend/src/tests/__mocks__/registration-service.mock.ts new file mode 100644 index 0000000000..4656147d30 --- /dev/null +++ b/backend/src/tests/__mocks__/registration-service.mock.ts @@ -0,0 +1,7 @@ +/** + * Create a mock object for Registration Service + */ +export default jest.fn().mockImplementation(() => ({ + onUnregister: jest.fn(), + updateRegistrationSettings: jest.fn() +})); diff --git a/backend/src/tests/__mocks__/reload-service.mock.ts b/backend/src/tests/__mocks__/reload-service.mock.ts index a48518920e..f6f11b13c1 100644 --- a/backend/src/tests/__mocks__/reload-service.mock.ts +++ b/backend/src/tests/__mocks__/reload-service.mock.ts @@ -38,6 +38,7 @@ export default jest.fn().mockImplementation(() => ({ onDeleteNorthSubscription: jest.fn(), onDeleteExternalNorthSubscription: jest.fn(), onUpdateScanMode: jest.fn(), + restartLogger: jest.fn(), oibusEngine: { resetSouthMetrics: jest.fn(), resetNorthMetrics: jest.fn(), diff --git a/backend/src/web-server/controllers/registration.controller.spec.ts b/backend/src/web-server/controllers/registration.controller.spec.ts index 255c309f3f..c8650f457a 100644 --- a/backend/src/web-server/controllers/registration.controller.spec.ts +++ b/backend/src/web-server/controllers/registration.controller.spec.ts @@ -39,20 +39,20 @@ describe('Registration controller', () => { }); it('getRegistrationSettings() should return registration settings', async () => { - ctx.app.oibusService.getRegistrationSettings.mockReturnValue(registrationSettings); + ctx.app.repositoryService.registrationRepository.getRegistrationSettings.mockReturnValue(registrationSettings); await registrationController.getRegistrationSettings(ctx); - expect(ctx.app.oibusService.getRegistrationSettings).toHaveBeenCalled(); + expect(ctx.app.repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalled(); expect(ctx.ok).toHaveBeenCalledWith(registrationSettings); }); it('getRegistrationSettings() should return not found', async () => { - ctx.app.oibusService.getRegistrationSettings.mockReturnValue(null); + ctx.app.repositoryService.registrationRepository.getRegistrationSettings.mockReturnValue(null); await registrationController.getRegistrationSettings(ctx); - expect(ctx.app.oibusService.getRegistrationSettings).toHaveBeenCalled(); + expect(ctx.app.repositoryService.registrationRepository.getRegistrationSettings).toHaveBeenCalled(); expect(ctx.notFound).toHaveBeenCalledWith(); }); @@ -62,7 +62,7 @@ describe('Registration controller', () => { await registrationController.updateRegistrationSettings(ctx); expect(validator.validate).toHaveBeenCalledWith(schema, registrationCommand); - expect(ctx.app.oibusService.updateRegistrationSettings).toHaveBeenCalledWith(registrationCommand); + expect(ctx.app.registrationService.updateRegistrationSettings).toHaveBeenCalledWith(registrationCommand); expect(ctx.noContent).toHaveBeenCalled(); }); @@ -76,14 +76,14 @@ describe('Registration controller', () => { await registrationController.updateRegistrationSettings(ctx); expect(validator.validate).toHaveBeenCalledWith(schema, registrationCommand); - expect(ctx.app.oibusService.updateRegistrationSettings).not.toHaveBeenCalledWith(); + expect(ctx.app.registrationService.updateRegistrationSettings).not.toHaveBeenCalledWith(); expect(ctx.badRequest).toHaveBeenCalledWith(validationError.message); }); it('unregister() should call unregister from oibus service', async () => { await registrationController.unregister(ctx); - expect(ctx.app.oibusService.unregister).toHaveBeenCalledTimes(1); + expect(ctx.app.registrationService.onUnregister).toHaveBeenCalledTimes(1); expect(ctx.noContent).toHaveBeenCalled(); }); }); diff --git a/backend/src/web-server/controllers/registration.controller.ts b/backend/src/web-server/controllers/registration.controller.ts index fc82453174..a08ed11a1b 100644 --- a/backend/src/web-server/controllers/registration.controller.ts +++ b/backend/src/web-server/controllers/registration.controller.ts @@ -4,7 +4,7 @@ import AbstractController from './abstract.controller'; export default class RegistrationController extends AbstractController { async getRegistrationSettings(ctx: KoaContext<void, RegistrationSettingsDTO>): Promise<RegistrationSettingsDTO> { - const registrationSettings = ctx.app.oibusService.getRegistrationSettings(); + const registrationSettings = ctx.app.repositoryService.registrationRepository.getRegistrationSettings(); if (!registrationSettings) { return ctx.notFound(); } @@ -17,15 +17,15 @@ export default class RegistrationController extends AbstractController { try { await this.validate(ctx.request.body); const command = ctx.request.body as RegistrationSettingsCommandDTO; - await ctx.app.oibusService.updateRegistrationSettings(command); + await ctx.app.registrationService.updateRegistrationSettings(command); return ctx.noContent(); } catch (error: any) { ctx.badRequest(error.message); } } - unregister(ctx: KoaContext<any, any>) { - ctx.app.oibusService.unregister(); + async unregister(ctx: KoaContext<any, any>) { + await ctx.app.registrationService.onUnregister(); return ctx.noContent(); } } diff --git a/backend/src/web-server/controllers/validators/engine.validator.spec.ts b/backend/src/web-server/controllers/validators/engine.validator.spec.ts index 398a447e84..4720a57786 100644 --- a/backend/src/web-server/controllers/validators/engine.validator.spec.ts +++ b/backend/src/web-server/controllers/validators/engine.validator.spec.ts @@ -30,12 +30,13 @@ const dataProviders: DataProvider[] = [ console: null, file: null, database: null, - loki: null + loki: null, + oia: null } }, isValid: false, errorMessage: - '"logParameters.console" must be of type object. "logParameters.file" must be of type object. "logParameters.database" must be of type object. "logParameters.loki" must be of type object' + '"logParameters.console" must be of type object. "logParameters.file" must be of type object. "logParameters.database" must be of type object. "logParameters.loki" must be of type object. "logParameters.oia" must be of type object' }, { dto: { @@ -60,15 +61,18 @@ const dataProviders: DataProvider[] = [ level: null, interval: null, address: null, - tokenAddress: null, username: null, password: null + }, + oia: { + level: null, + interval: null } } }, isValid: false, errorMessage: - '"name" must be a string. "port" must be a number. "proxyEnabled" must be a boolean. "proxyPort" must be a number. "logParameters.console.level" must be a string. "logParameters.file.level" must be a string. "logParameters.file.maxFileSize" must be a number. "logParameters.file.numberOfFiles" must be a number. "logParameters.database.level" must be a string. "logParameters.database.maxNumberOfLogs" must be a number. "logParameters.loki.level" must be a string. "logParameters.loki.interval" must be a number. "logParameters.loki.address" must be a string. "logParameters.loki.tokenAddress" must be a string. "logParameters.loki.username" must be a string. "logParameters.loki.password" must be a string' + '"name" must be a string. "port" must be a number. "proxyEnabled" must be a boolean. "proxyPort" must be a number. "logParameters.console.level" must be a string. "logParameters.file.level" must be a string. "logParameters.file.maxFileSize" must be a number. "logParameters.file.numberOfFiles" must be a number. "logParameters.database.level" must be a string. "logParameters.database.maxNumberOfLogs" must be a number. "logParameters.loki.level" must be a string. "logParameters.loki.interval" must be a number. "logParameters.loki.address" must be a string. "logParameters.loki.username" must be a string. "logParameters.loki.password" must be a string. "logParameters.oia.level" must be a string. "logParameters.oia.interval" must be a number' }, { dto: { @@ -93,15 +97,18 @@ const dataProviders: DataProvider[] = [ level: '', interval: '', address: '', - tokenAddress: '', username: '', password: '' + }, + oia: { + level: '', + interval: '' } } }, isValid: false, errorMessage: - '"name" is not allowed to be empty. "port" must be a number. "proxyEnabled" must be a boolean. "proxyPort" must be a number. "logParameters.console.level" is not allowed to be empty. "logParameters.file.level" is not allowed to be empty. "logParameters.file.maxFileSize" must be a number. "logParameters.file.numberOfFiles" must be a number. "logParameters.database.level" is not allowed to be empty. "logParameters.database.maxNumberOfLogs" must be a number. "logParameters.loki.level" is not allowed to be empty. "logParameters.loki.interval" must be a number' + '"name" is not allowed to be empty. "port" must be a number. "proxyEnabled" must be a boolean. "proxyPort" must be a number. "logParameters.console.level" is not allowed to be empty. "logParameters.file.level" is not allowed to be empty. "logParameters.file.maxFileSize" must be a number. "logParameters.file.numberOfFiles" must be a number. "logParameters.database.level" is not allowed to be empty. "logParameters.database.maxNumberOfLogs" must be a number. "logParameters.loki.level" is not allowed to be empty. "logParameters.loki.interval" must be a number. "logParameters.oia.level" is not allowed to be empty. "logParameters.oia.interval" must be a number' }, { dto: { @@ -126,9 +133,12 @@ const dataProviders: DataProvider[] = [ level: 'silent', interval: 60, address: '', - tokenAddress: '', username: '', password: '' + }, + oia: { + level: 'silent', + interval: 60 } } }, diff --git a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts index 550f08eb87..3feb91ef28 100644 --- a/backend/src/web-server/controllers/validators/oibus-validation-schema.ts +++ b/backend/src/web-server/controllers/validators/oibus-validation-schema.ts @@ -51,12 +51,12 @@ const engineSchema: Joi.ObjectSchema = Joi.object({ level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), interval: Joi.number().integer().required().min(10), address: Joi.string().required().allow(''), - tokenAddress: Joi.string().required().allow(''), username: Joi.string().required().allow(''), password: Joi.string().required().allow('') }), oia: Joi.object({ - level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace') + level: Joi.string().required().allow('silent', 'error', 'warning', 'info', 'debug', 'trace'), + interval: Joi.number().integer().required().min(10) }) }) }); diff --git a/backend/src/web-server/koa.ts b/backend/src/web-server/koa.ts index 244a6e61a4..d957e90a0a 100644 --- a/backend/src/web-server/koa.ts +++ b/backend/src/web-server/koa.ts @@ -7,6 +7,7 @@ import SouthService from '../service/south.service'; import OIBusService from '../service/oibus.service'; import NorthService from '../service/north.service'; import EngineMetricsService from '../service/engine-metrics.service'; +import RegistrationService from '../service/oia/registration.service'; interface KoaRequest<RequestBody> extends Request { body?: RequestBody; @@ -19,6 +20,7 @@ export interface KoaApplication extends Koa { southService: SouthService; northService: NorthService; oibusService: OIBusService; + registrationService: RegistrationService; engineMetricsService: EngineMetricsService; reloadService: ReloadService; encryptionService: EncryptionService; diff --git a/backend/src/web-server/middlewares/oibus.ts b/backend/src/web-server/middlewares/oibus.ts index 0a3690061c..fae1ee8d5b 100644 --- a/backend/src/web-server/middlewares/oibus.ts +++ b/backend/src/web-server/middlewares/oibus.ts @@ -7,6 +7,7 @@ import OIBusService from '../../service/oibus.service'; import NorthService from '../../service/north.service'; import SouthService from '../../service/south.service'; import EngineMetricsService from '../../service/engine-metrics.service'; +import RegistrationService from '../../service/oia/registration.service'; /** * OIBus middleware for Koa @@ -15,6 +16,7 @@ const oibus = ( id: string, repositoryService: RepositoryService, reloadService: ReloadService, + registrationService: RegistrationService, encryptionService: EncryptionService, southService: SouthService, northService: NorthService, @@ -27,6 +29,7 @@ const oibus = ( ctx.app.id = id; ctx.app.repositoryService = repositoryService; ctx.app.reloadService = reloadService; + ctx.app.registrationService = registrationService; ctx.app.encryptionService = encryptionService; ctx.app.southService = southService; ctx.app.northService = northService; diff --git a/backend/src/web-server/web-server.ts b/backend/src/web-server/web-server.ts index 9fc5d9f98c..48dbfe9adc 100644 --- a/backend/src/web-server/web-server.ts +++ b/backend/src/web-server/web-server.ts @@ -22,6 +22,7 @@ import SouthService from '../service/south.service'; import OIBusService from '../service/oibus.service'; import NorthService from '../service/north.service'; import EngineMetricsService from '../service/engine-metrics.service'; +import RegistrationService from '../service/oia/registration.service'; /** * Class Server - Provides the web client and establish socket connections. @@ -38,6 +39,7 @@ export default class WebServer { port: number, private readonly encryptionService: EncryptionService, private readonly reloadService: ReloadService, + private readonly registrationService: RegistrationService, private readonly repositoryService: RepositoryService, private readonly southService: SouthService, private readonly northService: NorthService, @@ -88,6 +90,7 @@ export default class WebServer { this._id, this.repositoryService, this.reloadService, + this.registrationService, this.encryptionService, this.southService, this.northService, diff --git a/backend/tsconfig.app.json b/backend/tsconfig.app.json index 70d25a0f84..3b0d5fd3a4 100644 --- a/backend/tsconfig.app.json +++ b/backend/tsconfig.app.json @@ -6,7 +6,7 @@ "files": [ "src/index.ts", "src/service/logger/sqlite-transport.ts", - "src/service/logger/loki-transport.ts", + "src/service/logger/oianalytics-transport.ts", ], "include": [ "src/db/**/*.ts", diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.html b/frontend/src/app/engine/edit-engine/edit-engine.component.html index 9d09a7ff55..f455b19865 100644 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.html +++ b/frontend/src/app/engine/edit-engine/edit-engine.component.html @@ -152,21 +152,13 @@ <h1 translate="engine.edit-title"></h1> <div class="row"> <!-- Address --> - <div class="col-4"> + <div class="col-6"> <div class="form-group"> <label class="form-label" for="loki-address" translate="engine.logger.loki.address"></label> <input formControlName="address" id="loki-address" class="form-control" /> <val-errors controlName="address"></val-errors> </div> </div> - <!-- Token address --> - <div class="col-4"> - <div class="form-group"> - <label class="form-label" for="loki-token-address" translate="engine.logger.loki.token-address"></label> - <input formControlName="tokenAddress" id="loki-token-address" class="form-control" /> - <val-errors controlName="tokenAddress"></val-errors> - </div> - </div> </div> <div class="row"> @@ -199,6 +191,18 @@ <h1 translate="engine.edit-title"></h1> </select> </div> </div> + + <!-- Interval --> + <div class="col-4"> + <div class="form-group"> + <label class="form-label" for="oia-interval" translate="engine.logger.oia.interval"></label> + <div class="input-group"> + <input type="number" formControlName="interval" id="oia-interval" class="form-control" /> + <span class="input-group-text" translate="common.unit.s"></span> + </div> + <val-errors controlName="interval"></val-errors> + </div> + </div> </div> </ng-container> </div> diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts b/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts index fd8d2528be..a9b709541b 100644 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts +++ b/frontend/src/app/engine/edit-engine/edit-engine.component.spec.ts @@ -71,14 +71,6 @@ class EditEngineComponentTester extends ComponentTester<EditEngineComponent> { return this.input('#loki-address'); } - get lokiProxy() { - return this.select('#loki-proxy'); - } - - get lokiTokenAddress() { - return this.input('#loki-token-address'); - } - get lokiUsername() { return this.input('#loki-username'); } @@ -91,6 +83,10 @@ class EditEngineComponentTester extends ComponentTester<EditEngineComponent> { return this.select('#oia-level')!; } + get oiaInterval() { + return this.input('#oia-interval'); + } + get submitButton() { return this.button('#save-button')!; } @@ -124,12 +120,12 @@ describe('EditEngineComponent', () => { level: 'error', interval: 60, address: 'http://loki.oibus.com', - tokenAddress: 'http://token-address.oibus.com', username: 'oibus', password: 'pass' }, oia: { - level: 'silent' + level: 'silent', + interval: 60 } } }; @@ -171,11 +167,10 @@ describe('EditEngineComponent', () => { expect(tester.lokiLevel).toHaveSelectedLabel('Error'); expect(tester.lokiInterval).toHaveValue(engineSettings.logParameters.loki.interval.toString()); expect(tester.lokiAddress).toHaveValue(engineSettings.logParameters.loki.address); - expect(tester.lokiTokenAddress).toHaveValue(engineSettings.logParameters.loki.tokenAddress); - expect(tester.lokiAddress).toHaveValue(engineSettings.logParameters.loki.address); expect(tester.lokiUsername).toHaveValue(engineSettings.logParameters.loki.username); expect(tester.lokiPassword).toHaveValue(engineSettings.logParameters.loki.password); expect(tester.oiaLevel).toHaveSelectedLabel('Silent'); + expect(tester.oiaInterval).toHaveValue(engineSettings.logParameters.oia.interval.toString()); }); it('should update engine settings', () => { @@ -219,12 +214,12 @@ describe('EditEngineComponent', () => { level: 'silent', interval: 60, address: 'http://loki.oibus.com', - tokenAddress: 'http://token-address.oibus.com', username: 'oibus', password: 'pass' }, oia: { - level: 'error' + level: 'error', + interval: 60 } } }); diff --git a/frontend/src/app/engine/edit-engine/edit-engine.component.ts b/frontend/src/app/engine/edit-engine/edit-engine.component.ts index 5a5de4940d..3dcc5aca1f 100644 --- a/frontend/src/app/engine/edit-engine/edit-engine.component.ts +++ b/frontend/src/app/engine/edit-engine/edit-engine.component.ts @@ -43,12 +43,12 @@ export class EditEngineComponent implements OnInit { level: ['silent' as LogLevel, Validators.required], interval: [null as number | null, [Validators.required, Validators.min(10)]], address: ['', Validators.pattern(/http.*/)], - tokenAddress: ['', Validators.pattern(/http.*/)], username: null as string | null, password: null as string | null }), oia: this.fb.group({ - level: ['silent' as LogLevel, Validators.required] + level: ['silent' as LogLevel, Validators.required], + interval: [null as number | null, [Validators.required, Validators.min(10)]] }) }) }); @@ -96,12 +96,12 @@ export class EditEngineComponent implements OnInit { level: formValue.logParameters!.loki!.level!, interval: formValue.logParameters!.loki!.interval!, address: formValue.logParameters!.loki!.address!, - tokenAddress: formValue.logParameters!.loki!.tokenAddress!, username: formValue.logParameters!.loki!.username!, password: formValue.logParameters!.loki!.password! }, oia: { - level: formValue.logParameters!.oia!.level! + level: formValue.logParameters!.oia!.level!, + interval: formValue.logParameters!.oia!.interval! } } }; diff --git a/frontend/src/app/engine/engine-detail.component.html b/frontend/src/app/engine/engine-detail.component.html index e5be18e807..1a5e0868b5 100644 --- a/frontend/src/app/engine/engine-detail.component.html +++ b/frontend/src/app/engine/engine-detail.component.html @@ -71,16 +71,27 @@ <h1 class="oib-title" translate="engine.title"> <tr> <td translate="engine.general-settings.log-levels"></td> <td> - <span translate="engine.general-settings.console"></span>:<span>{{ engineSettings.logParameters.console.level }}</span> - <span class="ms-2" translate="engine.general-settings.file"></span>:<span - >{{ engineSettings.logParameters.file.level }}</span - > - <span class="ms-2" translate="engine.general-settings.database"></span>:<span - >{{ engineSettings.logParameters.database.level }}</span - > - <span class="ms-2" translate="engine.general-settings.loki"></span>:<span - >{{ engineSettings.logParameters.loki.level }}</span - > + <span + translate="engine.general-settings.console" + [translateParams]="{ level: engineSettings.logParameters.console.level }" + ></span> + <span class="mx-1">|</span> + <span + translate="engine.general-settings.file" + [translateParams]="{ level: engineSettings.logParameters.file.level }" + ></span> + <span class="mx-1">|</span> + <span + translate="engine.general-settings.database" + [translateParams]="{ level: engineSettings.logParameters.database.level }" + ></span> + <span class="mx-1">|</span> + <span + translate="engine.general-settings.loki" + [translateParams]="{ level: engineSettings.logParameters.loki.level }" + ></span> + <span class="mx-1">|</span> + <span translate="engine.general-settings.oia" [translateParams]="{ level: engineSettings.logParameters.oia.level }"></span> </td> </tr> <tr> diff --git a/frontend/src/app/engine/engine-detail.component.spec.ts b/frontend/src/app/engine/engine-detail.component.spec.ts index 4e415e03e0..1d12a76ccc 100644 --- a/frontend/src/app/engine/engine-detail.component.spec.ts +++ b/frontend/src/app/engine/engine-detail.component.spec.ts @@ -76,8 +76,13 @@ describe('EngineDetailComponent', () => { }, loki: { level: 'error' + }, + oia: { + level: 'silent' } - } + }, + proxyEnabled: true, + proxyPort: 8888 } as EngineSettingsDTO; beforeEach(() => { @@ -120,10 +125,11 @@ describe('EngineDetailComponent', () => { expect(table[1]).toContainText('Port'); expect(table[1]).toContainText('2223'); expect(table[2]).toContainText('Log levels'); - expect(table[2]).toContainText('Console:silent'); - expect(table[2]).toContainText('File:trace'); - expect(table[2]).toContainText('Database:silent'); - expect(table[2]).toContainText('Loki:error'); + expect(table[2]).toContainText('Console: silent|'); + expect(table[2]).toContainText('File: trace|'); + expect(table[2]).toContainText('Database: silent|'); + expect(table[2]).toContainText('Loki: error'); + expect(table[3]).toContainText('Proxy serverEnabled on port 8888'); expect(tester.scanModeList).toBeDefined(); expect(tester.externalSourceList).toBeDefined(); diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index befa822890..db023da2aa 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -311,10 +311,11 @@ "name": "Name", "port": "Port", "log-levels": "Log levels", - "console": "Console", - "file": "File", - "database": "Database", - "loki": "Loki" + "console": "Console: {{ level }}", + "file": "File: {{ level }}", + "database": "Database: {{ level }}", + "loki": "Loki: {{ level }}", + "oia": "OIAnalytics: {{ level }}" }, "proxy-settings": { "title": "Proxy server", @@ -343,17 +344,17 @@ "loki": { "title": "Loki", "level": "Loki level", - "interval": "Loki Interval", + "interval": "Loki interval", "address": "Loki url", "proxy": "Loki proxy", "no-proxy": "No proxy", - "token-address": "Loki token url", "username": "Loki username", "password": "Loki password" }, "oia": { "title": "OIAnalytics logs", - "level": "OIA level" + "level": "OIAnalytics level", + "interval": "OIAnalytics interval" } }, "monitoring": { diff --git a/shared/model/engine.model.ts b/shared/model/engine.model.ts index bf9a983b7d..d7a8c23b02 100644 --- a/shared/model/engine.model.ts +++ b/shared/model/engine.model.ts @@ -42,7 +42,6 @@ interface DatabaseLogSettings extends BaseLogSettings { interface LokiLogSettings extends BaseLogSettings { interval: number; address: string; - tokenAddress: string; username: string; password: string; } @@ -50,7 +49,9 @@ interface LokiLogSettings extends BaseLogSettings { /** * Settings to write logs into a remote loki instance */ -interface OiaLogSettings extends BaseLogSettings {} +interface OiaLogSettings extends BaseLogSettings { + interval: number; +} /** * Logs settings used in the engine diff --git a/shared/model/logs.model.ts b/shared/model/logs.model.ts index 026924f5dc..cfb5573721 100644 --- a/shared/model/logs.model.ts +++ b/shared/model/logs.model.ts @@ -1,4 +1,5 @@ import { LogLevel, ScopeType } from './engine.model'; +import { Instant } from './types'; /** * DTO used for Log entries @@ -22,7 +23,7 @@ export interface PinoLog { scopeType: ScopeType; scopeId: string | null; scopeName: string | null; - time: number; + time: Instant; level: string; }