From c267990093646f0cf24183e7741d6986cab6eb4b Mon Sep 17 00:00:00 2001 From: Sergey Petrov Date: Wed, 14 Aug 2024 15:42:09 -0700 Subject: [PATCH] chore: v1.36.0 release --- .gitlab-ci.yml | 65 +- CHANGELOG.md | 27 + SUPPORT.md | 5 +- contributing/README.md | 26 +- contributing/docs_release.md | 1 + contributing/process_release.md | 4 + detailed_information.md | 66 +- docs/avr.rst | 2 +- docs/conf.py | 2 +- docs/data-modification.rst | 60 +- docs/faq.rst | 4 +- docs/installation.rst | 9 +- docs/memory-monitor.rst | 24 +- docs/prereqs.rst | 5 +- docs/quick-start.rst | 4 - docs/revision-history.rst | 8 +- docs/telemetry-system.rst | 50 +- docs/troubleshooting.rst | 45 +- examples/declarations/System/system.json | 8 +- examples/declarations/all_properties.json | 1 + .../declarations/consumers/Kafka/kafka.json | 21 +- .../consumers/Splunk/splunk_http_agent.json | 20 + .../iHealth_Poller/ihealth_all.json | 2 +- package-lock.json | 7109 ++++++++++------- package.json | 69 +- scripts/build/buildRpm.sh | 4 +- scripts/tests/unit-tests.sh | 28 + src/lib/activityRecorder.js | 91 - src/lib/appEvents.js | 160 + src/lib/config.js | 154 +- src/lib/constants.js | 38 +- src/lib/consumers.js | 154 - src/lib/consumers/DataDog/index.js | 46 +- src/lib/consumers/ElasticSearch/index.js | 2 +- src/lib/consumers/Generic_HTTP/index.js | 42 +- src/lib/consumers/Kafka/index.js | 183 +- .../consumers/OpenTelemetry_Exporter/index.js | 2 +- src/lib/consumers/Prometheus/index.js | 136 + .../Prometheus/utils.js} | 141 +- src/lib/consumers/README.md | 32 + src/lib/consumers/Splunk/dataMapping.js | 32 +- src/lib/consumers/Splunk/index.js | 465 +- .../Splunk/multiMetricEventConverter.js | 5 + src/lib/consumers/api.js | 437 + src/lib/consumers/default/index.js | 102 +- src/lib/consumers/index.js | 534 ++ src/lib/consumers/shared/http2patch.js | 8 +- src/lib/customKeywords.js | 138 +- src/lib/dataPipeline.js | 130 - src/lib/{ => dataPipeline}/actionProcessor.js | 6 +- src/lib/{ => dataPipeline}/dataFilter.js | 16 +- src/lib/{ => dataPipeline}/dataTagging.js | 13 +- src/lib/dataPipeline/forwarder.js | 159 + src/lib/dataPipeline/index.js | 203 + src/lib/declarationHistory/index.js | 98 + src/lib/declarationHistory/recorder.js | 59 + src/lib/declarationValidator.js | 46 +- src/lib/endpointLoader.js | 409 - src/lib/errors.js | 10 + src/lib/eventListener/dataPublisher.js | 6 +- src/lib/eventListener/index.js | 186 +- src/lib/eventListener/networkService.js | 24 +- src/lib/eventListener/parser.js | 17 +- src/lib/eventListener/stream.js | 6 +- src/lib/eventListener/streamService.js | 12 +- src/lib/forwarder.js | 74 - src/lib/ihealth.js | 268 - src/lib/ihealth/api/device.js | 356 + src/lib/ihealth/api/ihealth.js | 375 + src/lib/ihealth/api/qkview.js | 169 + src/lib/ihealth/index.js | 885 ++ src/lib/ihealth/normalize.js | 95 + src/lib/ihealth/poller.js | 984 +++ src/lib/ihealthPoller.js | 1445 ---- src/lib/logger.js | 15 +- src/lib/normalize.js | 45 - src/lib/paths.json | 104 +- src/lib/persistentStorage.js | 428 - src/lib/properties.json | 53 +- src/lib/pullConsumers.js | 228 - src/lib/pullConsumers/default/index.js | 60 - src/lib/pullConsumers/index.js | 275 + src/lib/requestHandlers/baseHandler.js | 91 - src/lib/requestHandlers/declareHandler.js | 115 - src/lib/requestHandlers/errorHandler.js | 96 - .../requestHandlers/eventListenerHandler.js | 79 - src/lib/requestHandlers/httpErrors.js | 110 - .../requestHandlers/ihealthPollerHandler.js | 90 - .../requestHandlers/pullConsumerHandler.js | 77 - src/lib/requestHandlers/router.js | 215 - .../requestHandlers/systemPollerHandler.js | 75 - src/lib/resourceMonitor/index.js | 332 +- src/lib/resourceMonitor/memoryMonitor.js | 57 +- src/lib/resourceMonitor/processingState.js | 185 + src/lib/resourceMonitor/utils.js | 26 +- .../contentTypes.js} | 9 +- src/lib/restAPI/errors.js | 129 + src/lib/restAPI/handlers/declare.js | 150 + src/lib/restAPI/handlers/error.js | 75 + src/lib/restAPI/handlers/eventListener.js | 66 + src/lib/restAPI/handlers/index.js | 37 + .../handlers/info.js} | 60 +- src/lib/restAPI/index.js | 293 + src/lib/restAPI/request.js | 77 + src/lib/restAPI/response.js | 50 + src/lib/restAPI/router.js | 239 + src/lib/runtimeConfig/index.js | 188 +- src/lib/runtimeConfig/task.js | 145 +- src/lib/runtimeConfig/updater.js | 73 +- src/lib/storage/index.js | 121 + src/lib/storage/storage.js | 372 + src/lib/systemPoller.js | 388 - src/lib/systemPoller/collector.js | 254 + src/lib/systemPoller/index.js | 898 +++ src/lib/systemPoller/loader.js | 584 ++ src/lib/systemPoller/poller.js | 864 ++ src/lib/systemPoller/properties.js | 289 + src/lib/systemPoller/utils.js | 324 + src/lib/systemStats.js | 597 -- src/lib/teemReporter/index.js | 90 + src/lib/{ => teemReporter}/teemReporter.js | 19 +- src/lib/tracerManager.js | 1 - src/lib/utils/assert.js | 1189 +++ src/lib/utils/config.js | 791 +- src/lib/utils/dacli.js | 419 + src/lib/utils/data.js | 7 +- src/lib/utils/device.js | 1620 ++-- src/lib/utils/eventEmitter.js | 35 +- .../shared/httpUtil.js => utils/http.js} | 66 +- src/lib/utils/ihealth.js | 855 -- src/lib/utils/misc.js | 100 +- src/lib/utils/moduleLoader.js | 2 +- src/lib/utils/promise.js | 17 + src/lib/utils/requests.js | 32 +- src/lib/utils/service.js | 157 +- src/lib/utils/structures/circularArray.js | 1 - src/lib/utils/systemStats.js | 190 - src/lib/utils/taskQueue.js | 354 + src/lib/utils/timers.js | 10 +- src/lib/utils/tracer.js | 412 +- src/nodejs/restWorker.js | 189 +- src/schema/1.36.0/actions_schema.json | 187 + src/schema/1.36.0/base_schema.json | 310 + src/schema/1.36.0/common_schema.json | 92 + src/schema/1.36.0/consumer_schema.json | 1485 ++++ src/schema/1.36.0/controls_schema.json | 174 + src/schema/1.36.0/endpoints_schema.json | 190 + src/schema/1.36.0/ihealth_poller_schema.json | 238 + src/schema/1.36.0/listener_schema.json | 85 + src/schema/1.36.0/namespace_schema.json | 92 + src/schema/1.36.0/pull_consumer_schema.json | 101 + src/schema/1.36.0/shared_schema.json | 50 + src/schema/1.36.0/system_poller_schema.json | 274 + src/schema/1.36.0/system_schema.json | 124 + src/schema/latest/base_schema.json | 4 +- src/schema/latest/common_schema.json | 92 + src/schema/latest/consumer_schema.json | 133 +- src/schema/latest/controls_schema.json | 14 +- src/schema/latest/ihealth_poller_schema.json | 4 +- src/schema/latest/system_poller_schema.json | 36 +- src/schema/latest/system_schema.json | 3 + test/functional/cloud/awsTests.js | 4 +- .../azureApplicationInsightsTests.js | 4 +- .../consumersTests/azureLogAnalyticsTests.js | 5 +- .../consumersTests/elasticsearchTests.js | 2 +- .../functional/consumersTests/f5CloudTests.js | 4 +- .../functional/consumersTests/fluentdTests.js | 2 +- .../googleCloudMonitoringTests.js | 2 + test/functional/consumersTests/kafkaTests.js | 76 +- .../openTelemetryExporterTests.js | 8 +- test/functional/consumersTests/splunkTests.js | 2 +- test/functional/consumersTests/statsdTests.js | 4 +- test/functional/deployment/declaration.yml | 48 - test/functional/dutTests.js | 38 +- .../shared/connectors/telemetryConnector.js | 4 +- test/unit/activityRecorderTests.js | 133 - test/unit/appEventsTests.js | 255 + test/unit/configTests.js | 990 ++- test/unit/constantsTests.js | 69 +- test/unit/consumers/awsUtilTests.js | 2 + .../azureApplicationInsightsConsumerTests.js | 42 +- .../consumers/consumers/Prometheus/index.js | 124 + test/unit/consumers/consumers/Splunk/index.js | 165 + .../unit/consumers/consumers/default/index.js | 44 + .../not-a-consumer/Splunk/index.js/index.js | 19 + test/unit/consumers/consumersTests.js | 1539 ++++ test/unit/consumers/dataDogConsumerTests.js | 5 +- test/unit/consumers/defaultConsumerTests.js | 2 +- .../consumers/elasticSearchConsumerTests.js | 2 +- test/unit/consumers/f5CloudConsumerTests.js | 2 +- test/unit/consumers/gcpUtilTests.js | 10 +- .../consumers/genericHTTPConsumerTests.js | 6 +- .../unit/consumers/googleCloudLoggingTests.js | 86 +- .../googleCloudMonitoringConsumerTests.js | 129 +- test/unit/consumers/kafkaConsumerTests.js | 317 +- .../consumers/openTelemetryExporterTests.js | 9 +- test/unit/consumers/splunkConsumerTests.js | 11 +- test/unit/consumersTests.js | 165 - test/unit/customEndpointsTests.js | 85 - .../configUtilTests/getComponentsTestsData.js | 759 +- .../hasEnabledComponentsTestsData.js | 7 - .../normalizeDeclarationEndpointsTestsData.js | 90 +- ...malizeDeclarationIHealthPollerTestsData.js | 608 +- ...rmalizeDeclarationPullConsumerTestsData.js | 176 +- ...rmalizeDeclarationSystemPollerTestsData.js | 327 +- .../normalizeDeclarationSystemTestsData.js | 247 +- ...normalizeDeclarationTraceValueTestsData.js | 73 +- test/unit/data/dataUtilTestsData.js | 11 + test/unit/data/deviceUtilTestsData.js | 180 - test/unit/data/endpointLoaderTestsData.js | 657 -- test/unit/data/systemPollerTestsData.js | 89 +- .../actionProcessorTests.js | 14 +- .../consumers/Generic_HTTP/index.js | 36 + .../consumers/Prometheus/index.js | 116 + .../dataPipeline/consumers/Splunk/index.js | 118 + .../dataPipeline/consumers/default/index.js | 36 + .../{ => dataPipeline}/dataFilterTests.js | 18 +- .../{ => dataPipeline}/dataTaggingTests.js | 8 +- test/unit/dataPipeline/serviceTests.js | 1104 +++ test/unit/dataPipeline/utils.js | 29 + test/unit/dataPipelineTests.js | 418 - .../declaration/ajvCustomKeywordsTests.js | 57 +- .../declaration/baseSchemaObjectsTests.js | 10 +- test/unit/declaration/classControlsTests.js | 61 +- .../awsCloudWatchTests.js | 9 +- .../classTelemetryConsumerTests/awsS3Tests.js | 9 +- .../azureApplicationInsightsTests.js | 9 +- .../azureLogAnalyticsTests.js | 10 +- .../dataDogTests.js | 15 +- .../defaultTests.js | 10 +- .../elasticSearchTests.js | 9 +- .../f5CloudTests.js | 12 +- .../genericHttpTests.js | 15 +- .../googleCloudLoggingTests.js | 9 +- .../googleCloudMonitoringTests.js | 9 +- .../graphiteTests.js | 9 +- .../classTelemetryConsumerTests/kafkaTests.js | 120 +- .../openTelemetryTests.js | 12 +- .../splunkTests.js | 73 +- .../statsdTests.js | 9 +- .../sumoLogicTests.js | 9 +- .../classTelemetryEndpointsTests.js | 10 +- .../classTelemetryIHealthPollerTests.js | 32 +- .../classTelemetryListenerTests.js | 10 +- .../classTelemetryNamespaceTests.js | 14 +- .../defaultTests.js | 10 +- .../prometheusTests.js | 10 +- .../classTelemetrySystemPollerTests.js | 92 +- .../declaration/classTelemetrySystemTests.js | 52 +- test/unit/declaration/common.js | 19 + test/unit/declaration/examplesTests.js | 9 +- test/unit/declaration/validatorTests.js | 12 +- .../declarationHistoryTests.js | 152 + test/unit/endpointLoaderTests.js | 427 - test/unit/eventListener/dataPublisherTests.js | 16 +- .../unit/eventListener/networkServiceTests.js | 75 +- test/unit/eventListener/parserTests.js | 31 +- test/unit/forwarderTests.js | 261 - test/unit/iHealthTests.js | 777 -- test/unit/ihealth/api/deviceTests.js | 546 ++ test/unit/ihealth/api/ihealthTests.js | 447 ++ test/unit/ihealth/api/mocks.js | 561 ++ test/unit/ihealth/api/qkviewDiagnostics.json | 88 + test/unit/ihealth/api/qkviewTests.js | 217 + test/unit/ihealth/helpers.js | 323 + test/unit/ihealth/pollerMock.js | 224 + test/unit/ihealth/pollerTests.js | 1022 +++ test/unit/ihealth/reportDiagnostics.json | 50 + test/unit/ihealth/serviceDemoTests.js | 1538 ++++ test/unit/ihealth/serviceTests.js | 1165 +++ test/unit/ihealthPollerTests.js | 1104 --- test/unit/loggerTests.js | 50 +- test/unit/persistentStorageTests.js | 490 -- test/unit/propertiesJsonTests.js | 127 - .../pullConsumers/defaultPullConsumerTests.js | 4 +- .../prometheusPullConsumerTests.js | 4 +- test/unit/pullConsumersTests.js | 288 - test/unit/requestHandlers/baseHandlerTests.js | 97 - .../requestHandlers/declareHandlerTests.js | 412 - .../unit/requestHandlers/errorHandlerTests.js | 142 - .../eventListenerHandlerTests.js | 142 - .../ihealthPollerHandlerTests.js | 269 - test/unit/requestHandlers/infoHandlerTests.js | 71 - .../pullConsumerHandlerTests.js | 149 - test/unit/requestHandlers/routerTests.js | 382 - .../systemPollerHandlerTests.js | 94 - .../resourceMonitor/memoryMonitorTests.js | 31 +- .../resourceMonitor/resourceMonitorTests.js | 1282 +-- test/unit/restAPI/handlers/declareTests.js | 521 ++ .../restAPI/handlers/eventListenerTests.js | 157 + test/unit/restAPI/handlers/infoTests.js | 96 + test/unit/restAPI/restAPIServiceTests.js | 934 +++ .../restAPI/testHandlers/badInterfaceAPI.js | 36 + .../unit/restAPI/testHandlers/debugHandler.js | 38 + .../restAPI/testHandlers/faultyHandler.js | 36 + test/unit/restAPI/testHandlers/index.js | 29 + .../restAPI/testHandlers/regularHandler.js | 95 + test/unit/restAPI/utils.js | 125 + test/unit/restWorkerTests.js | 419 +- test/unit/runtimeConfig/runtimeConfigTests.js | 1878 ++--- test/unit/runtimeConfig/shared.js | 88 + test/unit/runtimeConfig/updaterTests.js | 1311 ++- test/unit/shared/assert.js | 146 +- test/unit/shared/bigipAPIMock.js | 1095 +++ test/unit/shared/bootstrap.js | 13 +- test/unit/shared/dummies.js | 8 +- test/unit/shared/httpMock.js | 423 + test/unit/shared/restoreCache.js | 9 +- test/unit/shared/stubs.js | 492 +- test/unit/shared/tests/bigipConn.js | 60 + test/unit/shared/tests/bigipCreds.js | 118 + test/unit/shared/tests/httpProxy.js | 86 + test/unit/shared/tests/ihealthCreds.js | 52 + test/unit/shared/util.js | 189 +- test/unit/storage/serviceTests.js | 207 + test/unit/storage/storageTests.js | 475 ++ test/unit/systemPoller/collectorTests.js | 459 ++ .../unit/systemPoller/customEndpointsTests.js | 125 + .../data/contextTestsData.js} | 31 +- .../data/customEndpointsTestsData.js | 25 + .../collectClientSslProfiles.js | 5 - .../defaultProperties}/collectDeviceGroups.js | 12 +- .../defaultProperties}/collectGtmStats.js | 127 +- .../defaultProperties}/collectHttpProfiles.js | 5 - .../data/defaultProperties}/collectIRules.js | 5 - .../defaultProperties}/collectLtmPolicies.js | 5 - .../collectNetworkTunnels.js | 5 - .../data/defaultProperties}/collectPools.js | 8 +- .../collectServerSslProfiles.js | 5 - .../defaultProperties}/collectSslCerts.js | 6 - .../defaultProperties}/collectSystemStats.js | 273 +- .../data/defaultProperties}/collectTmstats.js | 54 +- .../collectVirtualServers.js | 503 +- .../data/filterStatsTestsData.js} | 38 +- .../unit/systemPoller/data/loaderTestsData.js | 872 ++ .../systemPoller/data/propertiesTestsData.js | 446 ++ .../data/utilsTestsData.js} | 0 .../systemPoller/defaultEndpointsTests.js | 237 + test/unit/systemPoller/helpers.js | 313 + test/unit/systemPoller/loaderTests.js | 1098 +++ test/unit/systemPoller/pollerMock.js | 425 + test/unit/systemPoller/pollerTests.js | 1349 ++++ test/unit/systemPoller/propertiesTests.js | 136 + test/unit/systemPoller/serviceTests.js | 1256 +++ test/unit/systemPoller/utilsTests.js | 122 + test/unit/systemPollerTests.js | 810 -- test/unit/systemStatsTests.js | 295 - test/unit/teemReporter/teemReporterTests.js | 312 + test/unit/teemReporterTests.js | 238 - test/unit/tracerManagerTests.js | 29 +- test/unit/utils/assertTests.js | 84 + test/unit/utils/configTests.js | 822 +- test/unit/utils/dacliTests.js | 556 ++ test/unit/utils/dataTests.js | 6 +- test/unit/utils/deviceTests.js | 2549 +++--- test/unit/utils/eventEmitterTests.js | 93 +- .../httpUtilTests.js => utils/httpTests.js} | 99 +- test/unit/utils/ihealthTests.js | 1651 ---- test/unit/utils/miscTests.js | 62 +- test/unit/utils/promiseTests.js | 19 + test/unit/utils/requestsTests.js | 57 +- test/unit/utils/serviceTests.js | 162 +- test/unit/utils/systemStatsTests.js | 69 - test/unit/utils/taskQueueTests.js | 510 ++ test/unit/utils/tracerTests.js | 96 +- versions.json | 2 +- 366 files changed, 54894 insertions(+), 29951 deletions(-) create mode 100644 examples/declarations/consumers/Splunk/splunk_http_agent.json create mode 100755 scripts/tests/unit-tests.sh delete mode 100644 src/lib/activityRecorder.js create mode 100644 src/lib/appEvents.js delete mode 100644 src/lib/consumers.js create mode 100644 src/lib/consumers/Prometheus/index.js rename src/lib/{pullConsumers/Prometheus/index.js => consumers/Prometheus/utils.js} (64%) create mode 100644 src/lib/consumers/api.js create mode 100644 src/lib/consumers/index.js delete mode 100644 src/lib/dataPipeline.js rename src/lib/{ => dataPipeline}/actionProcessor.js (97%) rename src/lib/{ => dataPipeline}/dataFilter.js (88%) rename src/lib/{ => dataPipeline}/dataTagging.js (95%) create mode 100644 src/lib/dataPipeline/forwarder.js create mode 100644 src/lib/dataPipeline/index.js create mode 100644 src/lib/declarationHistory/index.js create mode 100644 src/lib/declarationHistory/recorder.js delete mode 100644 src/lib/endpointLoader.js delete mode 100644 src/lib/forwarder.js delete mode 100644 src/lib/ihealth.js create mode 100644 src/lib/ihealth/api/device.js create mode 100644 src/lib/ihealth/api/ihealth.js create mode 100644 src/lib/ihealth/api/qkview.js create mode 100644 src/lib/ihealth/index.js create mode 100644 src/lib/ihealth/normalize.js create mode 100644 src/lib/ihealth/poller.js delete mode 100644 src/lib/ihealthPoller.js delete mode 100644 src/lib/persistentStorage.js delete mode 100644 src/lib/pullConsumers.js delete mode 100644 src/lib/pullConsumers/default/index.js create mode 100644 src/lib/pullConsumers/index.js delete mode 100644 src/lib/requestHandlers/baseHandler.js delete mode 100644 src/lib/requestHandlers/declareHandler.js delete mode 100644 src/lib/requestHandlers/errorHandler.js delete mode 100644 src/lib/requestHandlers/eventListenerHandler.js delete mode 100644 src/lib/requestHandlers/httpErrors.js delete mode 100644 src/lib/requestHandlers/ihealthPollerHandler.js delete mode 100644 src/lib/requestHandlers/pullConsumerHandler.js delete mode 100644 src/lib/requestHandlers/router.js delete mode 100644 src/lib/requestHandlers/systemPollerHandler.js create mode 100644 src/lib/resourceMonitor/processingState.js rename src/lib/{requestHandlers/connections.js => restAPI/contentTypes.js} (75%) create mode 100644 src/lib/restAPI/errors.js create mode 100644 src/lib/restAPI/handlers/declare.js create mode 100644 src/lib/restAPI/handlers/error.js create mode 100644 src/lib/restAPI/handlers/eventListener.js create mode 100644 src/lib/restAPI/handlers/index.js rename src/lib/{requestHandlers/infoHandler.js => restAPI/handlers/info.js} (53%) create mode 100644 src/lib/restAPI/index.js create mode 100644 src/lib/restAPI/request.js create mode 100644 src/lib/restAPI/response.js create mode 100644 src/lib/restAPI/router.js create mode 100644 src/lib/storage/index.js create mode 100644 src/lib/storage/storage.js delete mode 100644 src/lib/systemPoller.js create mode 100644 src/lib/systemPoller/collector.js create mode 100644 src/lib/systemPoller/index.js create mode 100644 src/lib/systemPoller/loader.js create mode 100644 src/lib/systemPoller/poller.js create mode 100644 src/lib/systemPoller/properties.js create mode 100644 src/lib/systemPoller/utils.js delete mode 100644 src/lib/systemStats.js create mode 100644 src/lib/teemReporter/index.js rename src/lib/{ => teemReporter}/teemReporter.js (92%) create mode 100644 src/lib/utils/assert.js create mode 100644 src/lib/utils/dacli.js rename src/lib/{consumers/shared/httpUtil.js => utils/http.js} (70%) delete mode 100644 src/lib/utils/ihealth.js delete mode 100644 src/lib/utils/systemStats.js create mode 100644 src/lib/utils/taskQueue.js create mode 100644 src/schema/1.36.0/actions_schema.json create mode 100644 src/schema/1.36.0/base_schema.json create mode 100644 src/schema/1.36.0/common_schema.json create mode 100644 src/schema/1.36.0/consumer_schema.json create mode 100644 src/schema/1.36.0/controls_schema.json create mode 100644 src/schema/1.36.0/endpoints_schema.json create mode 100644 src/schema/1.36.0/ihealth_poller_schema.json create mode 100644 src/schema/1.36.0/listener_schema.json create mode 100644 src/schema/1.36.0/namespace_schema.json create mode 100644 src/schema/1.36.0/pull_consumer_schema.json create mode 100644 src/schema/1.36.0/shared_schema.json create mode 100644 src/schema/1.36.0/system_poller_schema.json create mode 100644 src/schema/1.36.0/system_schema.json create mode 100644 src/schema/latest/common_schema.json delete mode 100644 test/unit/activityRecorderTests.js create mode 100644 test/unit/appEventsTests.js create mode 100644 test/unit/consumers/consumers/Prometheus/index.js create mode 100644 test/unit/consumers/consumers/Splunk/index.js create mode 100644 test/unit/consumers/consumers/default/index.js create mode 100644 test/unit/consumers/consumers/not-a-consumer/Splunk/index.js/index.js create mode 100644 test/unit/consumers/consumersTests.js delete mode 100644 test/unit/consumersTests.js delete mode 100644 test/unit/customEndpointsTests.js delete mode 100644 test/unit/data/endpointLoaderTestsData.js rename test/unit/{ => dataPipeline}/actionProcessorTests.js (91%) create mode 100644 test/unit/dataPipeline/consumers/Generic_HTTP/index.js create mode 100644 test/unit/dataPipeline/consumers/Prometheus/index.js create mode 100644 test/unit/dataPipeline/consumers/Splunk/index.js create mode 100644 test/unit/dataPipeline/consumers/default/index.js rename test/unit/{ => dataPipeline}/dataFilterTests.js (74%) rename test/unit/{ => dataPipeline}/dataTaggingTests.js (94%) create mode 100644 test/unit/dataPipeline/serviceTests.js create mode 100644 test/unit/dataPipeline/utils.js delete mode 100644 test/unit/dataPipelineTests.js create mode 100644 test/unit/declarationHistory/declarationHistoryTests.js delete mode 100644 test/unit/endpointLoaderTests.js delete mode 100644 test/unit/forwarderTests.js delete mode 100644 test/unit/iHealthTests.js create mode 100644 test/unit/ihealth/api/deviceTests.js create mode 100644 test/unit/ihealth/api/ihealthTests.js create mode 100644 test/unit/ihealth/api/mocks.js create mode 100644 test/unit/ihealth/api/qkviewDiagnostics.json create mode 100644 test/unit/ihealth/api/qkviewTests.js create mode 100644 test/unit/ihealth/helpers.js create mode 100644 test/unit/ihealth/pollerMock.js create mode 100644 test/unit/ihealth/pollerTests.js create mode 100644 test/unit/ihealth/reportDiagnostics.json create mode 100644 test/unit/ihealth/serviceDemoTests.js create mode 100644 test/unit/ihealth/serviceTests.js delete mode 100644 test/unit/ihealthPollerTests.js delete mode 100644 test/unit/persistentStorageTests.js delete mode 100644 test/unit/propertiesJsonTests.js delete mode 100644 test/unit/pullConsumersTests.js delete mode 100644 test/unit/requestHandlers/baseHandlerTests.js delete mode 100644 test/unit/requestHandlers/declareHandlerTests.js delete mode 100644 test/unit/requestHandlers/errorHandlerTests.js delete mode 100644 test/unit/requestHandlers/eventListenerHandlerTests.js delete mode 100644 test/unit/requestHandlers/ihealthPollerHandlerTests.js delete mode 100644 test/unit/requestHandlers/infoHandlerTests.js delete mode 100644 test/unit/requestHandlers/pullConsumerHandlerTests.js delete mode 100644 test/unit/requestHandlers/routerTests.js delete mode 100644 test/unit/requestHandlers/systemPollerHandlerTests.js create mode 100644 test/unit/restAPI/handlers/declareTests.js create mode 100644 test/unit/restAPI/handlers/eventListenerTests.js create mode 100644 test/unit/restAPI/handlers/infoTests.js create mode 100644 test/unit/restAPI/restAPIServiceTests.js create mode 100644 test/unit/restAPI/testHandlers/badInterfaceAPI.js create mode 100644 test/unit/restAPI/testHandlers/debugHandler.js create mode 100644 test/unit/restAPI/testHandlers/faultyHandler.js create mode 100644 test/unit/restAPI/testHandlers/index.js create mode 100644 test/unit/restAPI/testHandlers/regularHandler.js create mode 100644 test/unit/restAPI/utils.js create mode 100644 test/unit/runtimeConfig/shared.js create mode 100644 test/unit/shared/bigipAPIMock.js create mode 100644 test/unit/shared/httpMock.js create mode 100644 test/unit/shared/tests/bigipConn.js create mode 100644 test/unit/shared/tests/bigipCreds.js create mode 100644 test/unit/shared/tests/httpProxy.js create mode 100644 test/unit/shared/tests/ihealthCreds.js create mode 100644 test/unit/storage/serviceTests.js create mode 100644 test/unit/storage/storageTests.js create mode 100644 test/unit/systemPoller/collectorTests.js create mode 100644 test/unit/systemPoller/customEndpointsTests.js rename test/unit/{data/propertiesJsonTests/collectContext.js => systemPoller/data/contextTestsData.js} (88%) rename test/unit/{ => systemPoller}/data/customEndpointsTestsData.js (97%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectClientSslProfiles.js (99%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectDeviceGroups.js (95%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectGtmStats.js (95%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectHttpProfiles.js (98%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectIRules.js (98%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectLtmPolicies.js (97%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectNetworkTunnels.js (97%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectPools.js (99%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectServerSslProfiles.js (99%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectSslCerts.js (96%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectSystemStats.js (96%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectTmstats.js (96%) rename test/unit/{data/propertiesJsonTests => systemPoller/data/defaultProperties}/collectVirtualServers.js (69%) rename test/unit/{data/systemStatsTestsData.js => systemPoller/data/filterStatsTestsData.js} (94%) create mode 100644 test/unit/systemPoller/data/loaderTestsData.js create mode 100644 test/unit/systemPoller/data/propertiesTestsData.js rename test/unit/{data/systemStatsUtilTestsData.js => systemPoller/data/utilsTestsData.js} (100%) create mode 100644 test/unit/systemPoller/defaultEndpointsTests.js create mode 100644 test/unit/systemPoller/helpers.js create mode 100644 test/unit/systemPoller/loaderTests.js create mode 100644 test/unit/systemPoller/pollerMock.js create mode 100644 test/unit/systemPoller/pollerTests.js create mode 100644 test/unit/systemPoller/propertiesTests.js create mode 100644 test/unit/systemPoller/serviceTests.js create mode 100644 test/unit/systemPoller/utilsTests.js delete mode 100644 test/unit/systemPollerTests.js delete mode 100644 test/unit/systemStatsTests.js create mode 100644 test/unit/teemReporter/teemReporterTests.js delete mode 100644 test/unit/teemReporterTests.js create mode 100644 test/unit/utils/assertTests.js create mode 100644 test/unit/utils/dacliTests.js rename test/unit/{consumers/httpUtilTests.js => utils/httpTests.js} (74%) delete mode 100644 test/unit/utils/ihealthTests.js delete mode 100644 test/unit/utils/systemStatsTests.js create mode 100644 test/unit/utils/taskQueueTests.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f85ba514..384eee08 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,12 @@ variables: - "false" - "true" description: "Force docs to be build and published to GitLab on non-docs branches" - + SKIP_UNIT_TESTS: + value: "false" + options: + - "false" + - "true" + description: "Force CI/CD to skip 'test_nodeX' jobs on feature branches" ############################################################## # # @@ -42,6 +47,12 @@ variables: not_docs: rules: - if: $CI_COMMIT_BRANCH !~ /^docs/ + skip_unit_feat_brach: + rules: + - if: $CI_COMMIT_BRANCH == 'master' || $CI_COMMIT_BRANCH == 'develop' + - if: $SKIP_UNIT_TESTS == 'true' + when: never + - !reference [.rules_config, not_docs, rules] .run_unittest_cmd: &run_unittest_cmd @@ -52,9 +63,7 @@ variables: - echo "**** Node.js version - $nodeFullVer (bundled with npm@$(npm --version)) ****" - ver=$(echo $nodeFullVer | cut -d. -f 1) - installNPM=no -- if [ "$ver" = "v4" ]; then -- installNPM=5 -- elif [ "$ver" = "v8" ]; then +- if [ "$ver" = "v8" ]; then - installNPM=6 - elif [ "$ver" = "v10" ] || [ "$ver" = "v12" ] || [ "$ver" = "v14" ]; then - installNPM=7 @@ -72,7 +81,6 @@ variables: rules: - !reference [.rules_config, not_docs, rules] - .test_job_definition: &test_job_definition extends: - .job_definition @@ -175,43 +183,13 @@ lint: - *install_unittest_packages_cmd - npm run lint -# BIG-IP 13.x and BIG-IP 14.0 -test_node4: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:4.8.0 - # BIG-IP 14.1+ test_node8: extends: - .run_unittest image: ${ARTIFACTORY_DOCKER_HUB}/node:8.11.1 - -test_node10: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:10 - -test_node12: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:12 - -test_node14: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:14 - -test_node16: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:16 - -# disabled for now, failing unit tests -.test_node_latest: - extends: - - .run_unittest - image: ${ARTIFACTORY_DOCKER_HUB}/node:latest + rules: + - !reference [.rules_config, skip_unit_feat_brach, rules] # packages audit npm_audit: @@ -246,6 +224,8 @@ coverage: name: ${CI_COMMIT_REF_NAME}_unittests_coverage paths: - coverage + rules: + - !reference [.rules_config, skip_unit_feat_brach, rules] build_rpm: image: ${ATG_ARTIFACTORY_PUBLISH_URL}/${ATG_ARTIFACTORY_DOCKER_REPO}/f5-telemetry-streaming-rpm-builder-image:v1.2 @@ -326,7 +306,10 @@ test_functional: # enable this job - $RUN_FUNCTIONAL_TESTS == "true" script: - - export TEST_HARNESS_FILE=${CI_PROJECT_DIR}/harness_facts_flat.json + - if [ -z "${TEST_HARNESS_FILE}" ]; then + - export TEST_HARNESS_FILE=${CI_PROJECT_DIR}/harness_facts_flat.json + - fi + - echo "Harness data - ${TEST_HARNESS_FILE}" - ls ./dist -ls # really only need dev dependencies - *install_unittest_packages_cmd @@ -347,8 +330,6 @@ teardown_env: ############################################################## # END VIO # ############################################################## - - ############################################################## # BEGIN CLOUD # ############################################################## @@ -380,7 +361,6 @@ teardown_env_azure: script: - source ./scripts/functional_testing/azure/teardown_env.sh - # Azure Gov deploy_env_azure_gov: extends: @@ -402,14 +382,12 @@ test_functional_azure_gov: - build_rpm - deploy_env_azure_gov - teardown_env_azure_gov: extends: - .teardown_azure script: - source ./scripts/functional_testing/azure/teardown_env.sh 'gov' - deploy_env_aws: extends: - .base_aws @@ -469,7 +447,6 @@ pages: rules: - !reference [.rules_config, docs_only, rules] - # Publish docs to clouddocs.f5.com publish_docs_to_production: image: ${CONTAINTHEDOCS_IMAGE} diff --git a/CHANGELOG.md b/CHANGELOG.md index 95984958..f45788cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ # Changelog Changes to this project are documented in this file. More detail and links can be found in the Telemetry Streaming [Document Revision History](https://clouddocs.f5.com/products/extensions/f5-telemetry-streaming/latest/revision-history.html). +## 1.Y.Z +### Added +### Fixed +### Changed +### Removed + +## 1.36.0 +**Important**: Starting from BIG-IP Telemetry Streaming version 1.36.0, BIG-IP Telemetry Streaming no longer supports BIG-IP 13.1 to 15.0.x. However, if you are still using the BIG-IP 13.1 to 15.0.x versions, you can use BIG-IP Telemetry Streaming 1.35.0 or earlier. +### Added +- New "httpTimeout" option to configure HTTP timeout value for incoming REST API requests to Pull Consumers +- MBIPMP-37620: EndpointLoader support pagination +- MBIPMP-37621: Support for additional system poller settings: workers and httpAgentOpts +### Fixed +- Event Listener throws an uncaught error when the buffer pointer was set to a wrong position +- Event Listener allocates more memory than required for chunked data +- MBIPMP-41257: Update iHealth to use new authentication API +### Changed +- MBIPMP-37253: Update memoryMonitor.provisionedMemory limits (should allow to provision up to runtime.maxHeapSize value) +- MBIPMP-37255: Removed node 4 testing due to the end of support for BIG-IP 13.1 : ([Supported BIG-IP Versions](https://my.f5.com/manage/s/article/K5903)). +- MBIPMP-37374: Update Kafka consumer to support multi-host, formatting and custom opts +- MBIPMP-37631: Update Splunk consumer to use data queues, HTTP agent options, events batching +- Update Telemetry_System to require `passphrase` when `username` defined. +- Set default logging level to "info" instead of "debug". +- Allow to set single-digit minute value for iHealth polling interval +### Removed +- MBIPMP-37374: Kafka consumer support for ZooKeepeer + ## 1.35.0 ### Added - NEXTACC-414: Add Resource Monitor diff --git a/SUPPORT.md b/SUPPORT.md index 2c3cf7f4..0d5ce742 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -24,8 +24,7 @@ Currently supported versions: | Software Version | Release Type | First Customer Ship | End of Support | |------------------|------------------------|---------------------|------------------| | TS 1.33.0 | LTS | 22-Mar-2023 | Maintenance mode | -| TS 1.34.0 | Feature | 16-Jan-2024 | 16-Apr-2024 | -| TS 1.35.0 | Feature | 23-Feb-2024 | 23-May-2024 | +| TS 1.36.0 | Feature | 14-Aug-2024 | 14-Nov-2024 | Versions no longer supported: @@ -65,5 +64,7 @@ Versions no longer supported: | TS 1.30.0 | Feature | 15-Jul-2022 | 15-Oct-2022 | | TS 1.32.0 | Feature | 04-Oct-2022 | 04-Jan-2023 | | TS 1.27.1 | LTS | 19-Apr-2022 | 19-Apr-2023 | +| TS 1.34.0 | Feature | 16-Jan-2024 | 16-Apr-2024 | +| TS 1.35.0 | Feature | 23-Feb-2024 | 23-May-2024 | See the [Release notes](https://github.com/F5Networks/f5-telemetry-streaming/releases) and [Telemetry Streaming documentation](https://clouddocs.f5.com/products/extensions/f5-telemetry-streaming/latest/revision-history.html) for new features and issues resolved for each release. diff --git a/contributing/README.md b/contributing/README.md index a05fc9d3..8106fcbf 100644 --- a/contributing/README.md +++ b/contributing/README.md @@ -108,7 +108,7 @@ How does the project handle a typical `POST` request? "trace": false, "format": "default" }, - "schemaVersion": "1.35.0" + "schemaVersion": "1.36.0" } } ``` @@ -121,13 +121,13 @@ What happens in the system internals between request and response? - LX worker receives request which validates URI, etc. - ref: [restWorker.js](../src/nodejs/restWorker.js) - The appropriate handler processes the request - - ref: [router.js](../src/lib/requestHandlers/router.js) + - ref: [REST API](../src/lib/restAPI) - Request is validated using JSON schema and AJV, config event fires - ref: [config.js](../src/lib/config.js) - System poller, event listener, etc. configures system resources - - ref: [systemPoller.js](../src/lib/systemPoller.js), [eventListener.js](../src/lib/eventListener.js), etc. + - ref: [systemPoller.js](../src/lib/systemPoller), [eventListener](../src/lib/eventListener/), etc. - Client response sent with validated config - - ref: [declareHandler](../src/lib/requestHandlers/declareHandler.js) + - ref: [declareHandler](../src/lib/restAPI/handlers/declare.js) ```javascript return promise.then((config) => { this.code = 200; @@ -160,16 +160,14 @@ All core modules are included inside `../src/lib/` - Purpose: Hook for incoming HTTP requests - [config.js](../src/lib/config.js) - Purpose: Handle configuration actions... such as validation, persistent storage, etc. -- [systemPoller.js](../src/lib/systemPoller.js) +- [systemPoller](../src/lib/systemPoller/) - Purpose: Handles CRUD-like actions for any system pollers required based on client configuration - - Related: See [iHealthPoller.js](../src/lib/iHealthPoller.js) -- [eventListener.js](../src/lib/eventListener/index.js) + - Related: See [iHealthPoller.js](../src/lib/ihealth/) +- [eventListener](../src/lib/eventListener/) - Purpose: Handles CRUD-like actions for any event listeners required based on client configuration. -- [systemStats.js](../src/lib/systemStats.js) - - Purpose: Called by system poller to create stats object based on the static JSON configuration files available in `config/` directory such as [properties.json](../src/lib/properties.json) -- [consumers.js](../src/lib/consumers.js) +- [consumers](../src/lib/consumers/) - Purpose: Handles load/unload actions for any consumers required based on client configuration. Consumers must exist in `consumers` directory, see [Adding a New Consumer](#adding-a-new-consumer) -- [forwarder.js](../src/lib/forwarder.js) +- [dataPipeline](../src/lib/dataPipeline/) - Purpose: Handles calling each loaded consumer when an event is ready for forwarding (system poller event, event listener event, etc.) --- @@ -198,13 +196,17 @@ Collect the raw data from the device by adding a new endpoint to the paths confi "path": "/mgmt/tm/sys/someEndpoint", // REST endpoint "includeStats": true, // Certain data is only available via /mgmt/tm/sys/someEndpoint as opposed to /mgmt/tm/sys/someEndpoint/stats, this property accommodates for this by making call to /stats (for each item) and adding that data to the original object "expandReferences": { "membersReference": { "endpointSuffix": "/stats" } }, // Certain data requires getting a list of objects and then in each object expanding/following references to a child object. 'membersReference' is the name of that key (currently looking under 'items' in the data returned) and will result in self link data being retrieved and 'membersReference' key being replaced with that data. If 'endpointSuffix' is supplied, a suffix is added to each self link prior to retrieval, otherwise, the value of self link as is will be used. In cases like gslb where both config and stats are needed, both the `link` and `link/stats` need to be fetched, hence, the resulting config is "expandReferences": { "membersReference": { "includeStats": true } }, which is equivalent to "expandReferences": { "membersReference": { "endpointSuffix": "", "includeStats": true } }. TODO: revisit keywords/ naming here to consolidate and avoid confusion - "endpointFields": [ "name", "fullPath", "selfLink", "ipProtocol", "mask" ], // Will collect only these fields from the endpoint. Useful when using includeStats and the same property exists in both endpoints. Also can be used instead of a large exclude/include statement in properties.json "body": "{ \"command\": \"run\", \"utilCmdArgs\": \"-c \\\"/bin/df -P | /usr/bin/tr -s ' ' ','\\\"\" }", // Certain information may require using POST instead of GET and require an HTTP body, if body is defined that gets used along with a POST. Body can be either string or object "name": "someStatRef", // Alternate name to reference in properties.json, default is to use the endpoint "ignoreCached": true // Invalidate cached response of previous request to endpoint } ``` +*Response caching:* When endpoint validates against at least one of the following constraints then response will not be cached: +- **pagination** property set to `true` +- **body** property used to specify a body for HTTP POST request +- **ignoreCached** property omitted or not equals to `false` + --- #### Adding System Poller Stats - Properties.json diff --git a/contributing/docs_release.md b/contributing/docs_release.md index 52d3b79d..6d65c9f8 100644 --- a/contributing/docs_release.md +++ b/contributing/docs_release.md @@ -31,6 +31,7 @@ If you need to update docs for the most recent publicly available release: If you need to update docs for the LST release: - create `docs` (any name allowed) branch off `docs-X.Y.Z`, where X.Y.Z is the LTS release version +- update CI file to look like the one from `docs-1.27.1` branch (do not forget to change the target version in the file) - do docs changes - once the work done, create MR to merge `docs` branch to `docs-X.Y.Z-staging` - review your changes once deployed to GitLab Pages diff --git a/contributing/process_release.md b/contributing/process_release.md index caecdff0..f6bd8ee3 100644 --- a/contributing/process_release.md +++ b/contributing/process_release.md @@ -67,6 +67,8 @@ * 1.33.0 - 22.1 MB * 1.34.0 - 18.4 MB * 1.35.0 - 18.4 MB + * 1.36.0 - 20.6 MB (NOTE: inclusion of new version of Kafka library) + * 1.37.0 - ???? MB * Install build to BIG-IP, navigate to folder `/var/config/rest/iapps/f5-telemetry/` and check following: * Run `du -sh` and check that folder's size (shouldn't be much greater than previous versions): * 1.4.0 - 65 MB @@ -100,6 +102,8 @@ * 1.32.0 - 154 MB * 1.33.0 - 164 MB * 1.35.0 - 164 MB + * 1.36.0 - 154 MB + * 1.37.0 - ??? MB * Check `node_modules` folder - if you see `eslint`, `mocha` or something else from [package.json](package.json) `devDependencies` section - something wrong with build process. Probably some `npm` flags are work as not expected and it MUST BE FIXED before publishing. * Ensure that all tests (unit tests and functional tests passed) * Optional: Ensure that your local tags match remote. If not, remove all and re-fetch: diff --git a/detailed_information.md b/detailed_information.md index 77f54ea8..4532c511 100644 --- a/detailed_information.md +++ b/detailed_information.md @@ -371,7 +371,7 @@ Website: [https://kafka.apache.org/](https://kafka.apache.org/). Required information: -- Host: The address of the Kafka system. +- Host: The address of the Kafka system. This can be a string with a single host or an array containing multiple addresses. - Protocol: The port of the Kafka system. Options: ```binaryTcp``` or ```binaryTcpTls```. Default is ```binaryTcpTls```. - Port: The port of the Kafka system. - Topic: The topic where data should go within the Kafka system. @@ -379,7 +379,34 @@ Required information: - Username: The username for authentication. - Passphrase: The passphrase for authentication. -Note: More information about installing Kafka can be found [here](https://kafka.apache.org/quickstart). +Optional parameters: +- Format: Toggles formatting of data. Options: ```default``` (no additional formatting as with TS versions < 1.36) and ```split``` (splits system information into multiple smaller messages). +- Partitioner Type: Allows the message to be sent using a chosen partitioning strategy. Options: + - ```default``` uses the default or partition at index 0 + - ```random``` pick from available partitions randomly + - ```cyclic``` will cycle through the available partitions + - ```keyed``` use a specific partition with key (a value for```partitionKey``` must be provided) +- Partition Key: Key used to lookup a partition. Required when Partitioner Type is ```keyed```. Must not be specified if using other partitioner types. +- CustomOpts: Custom settings to pass to KafkaClient if using TS versions >= 1.36. These are a subset of what the [kafka-node library](https://github.com/SOHU-Co/kafka-node) supports. Please see the following example containing valid options: + ```json + "customOpts": [ + { "name": "connectTimeout", "value": 10000 }, + { "name": "requestTimeout", "value": 10000 }, + { "name": "idleConnection", "value": 10 }, + { "name": "maxAsyncRequests", "value": 50 }, + { "name": "connectRetryOptions.retries", "value": 10 }, + { "name": "connectRetryOptions.factor", "value": 3 }, + { "name": "connectRetryOptions.minTimeout", "value": 3000 }, + { "name": "connectRetryOptions.maxTimeout", "value": 10000 }, + { "name": "connectRetryOptions.randomize", "value": false }, + ] + ``` + +#### TS, Kafka and Kafka Client (kafka-node) Compatibility + +##### TS versions prior to v1.36 + +Use TS versions prior to v1.36 with ZooKeeper deployments. The previous TS releases use an older version of kafka-node library, which has dropped support for ZooKeeper apis for new versions. ZooKeeper itself has been marked as deprecated since the Kafka 3.5.0 release and will be removed in Apache Kafka 4.0. For more information, please see the documentation for [ZooKeeper Deprecation](https://kafka.apache.org/documentation/#zk_depr) ```json { @@ -389,17 +416,40 @@ Note: More information about installing Kafka can be found [here](https://kafka. "host": "192.168.2.1", "protocol": "binaryTcpTls", "port": 9092, + "topic": "f5-telemetry" + } +} +``` + +##### TS versions v1.36 or later + +Use with Kafka Raft (KRaft) mode deployments. Requires BIG-IP versions with Node >= 8.11.1. Supports option to split System Poller data into multiple smaller messages. Also supports multiple hosts and additional Kafka client customization. + +```json +{ + "My_Consumer": { + "class": "Telemetry_Consumer", + "type": "Kafka", + "host": ["192.168.2.10", "192.168.2.11"], + "protocol": "binaryTcpTls", + "port": 9092, "topic": "f5-telemetry", "authenticationProtocol": "SASL-PLAIN", "username": "username", "passphrase": { "cipherText": "secretkey" - } - } + }, + "format": "split", + "partitionerType": "cyclic", + "customOpts": [ + { "name": "connectTimeout", "value": 10000 } + ] } } ``` +Note: More information about installing Kafka can be found [here](https://kafka.apache.org/quickstart). + ### ElasticSearch Website: [https://www.elastic.co/](https://www.elastic.co/). @@ -698,7 +748,7 @@ Note: available only when `debug` is turned on. { "system": { "hostname": "telemetry.bigip.com", - "version": "14.0.0", + "version": "15.1.0", "versionBuild": "0.0.2", "location": "Seattle", "description": "Telemetry BIG-IP", @@ -1741,7 +1791,7 @@ Output "dest_port":"80", "device_product":"Advanced Firewall Module", "device_vendor":"F5", - "device_version":"14.0.0", + "device_version":"15.1.0", "drop_reason":"Policy", "errdefs_msgno":"23003137", "errdefs_msg_name":"Network Event", @@ -1899,9 +1949,7 @@ Output Configuration - Modify AVR streaming configuration - - BIG-IP 13.x: - - TMSH: ```modify analytics global-settings { ecm-address 127.0.0.1 ecm-port 6514 use-ecm enabled use-offbox enabled }``` - - BIG-IP 14.x: + - BIG-IP 15.1.x: - TMSH: ```modify analytics global-settings { offbox-protocol tcp offbox-tcp-addresses add { 127.0.0.1 } offbox-tcp-port 6514 use-offbox enabled }``` Output diff --git a/docs/avr.rst b/docs/avr.rst index a26b0eba..a2ae9a87 100644 --- a/docs/avr.rst +++ b/docs/avr.rst @@ -29,7 +29,7 @@ AS3 compatible command for TMSH: .. code-block:: bash - modify analytics global-settings { external-logging-publisher /Common/Shared/telemetry_publisher offbox-protocol hsl use-offbox enabled } + modify analytics global-settings { external-logging-publisher /Common/telemetry_publisher offbox-protocol hsl use-offbox enabled } TMSH command: diff --git a/docs/conf.py b/docs/conf.py index 51127f46..ec9232c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,7 +79,7 @@ # The short X.Y version. version = u'' # The full version, including alpha/beta/rc tags. -release = u'1.34.0' +release = u'1.35.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/data-modification.rst b/docs/data-modification.rst index 720e9d28..0681de75 100644 --- a/docs/data-modification.rst +++ b/docs/data-modification.rst @@ -37,20 +37,16 @@ The following is an example of an Action chain with a description after the exam }, { "setTag": { - "vsInfo": { - "app": "`A`" - } + "serviceApp": "`A`" } }, { "includeData": {}, "locations": { "virtualServers": { - ".*": { - "vsInfo": true, + ".*": "name": true, - "bits.in": true - } + "clientside.bitsIn": true } } } @@ -60,7 +56,7 @@ The following is an example of an Action chain with a description after the exam 1. First BIG-IP Telemetry Streaming will exclude **system**. -2. Next BIG-IP Telemetry Streaming will apply **vsInfo** tag to known locations (if the **locations** property is not specified, then the tag is applied to **virtualServers**, **pools** etc.) +2. Next BIG-IP Telemetry Streaming will apply tags to known locations (if the **locations** property is not specified, then the tag is applied to **virtualServers**, **pools** etc.) 3. Finally BIG-IP Telemetry Streaming keeps all **virtualServers** data with properties defined in **locations** only. As result of execution output will look like: @@ -69,13 +65,10 @@ As result of execution output will look like: { "virtualServers": { - "/Common/app.app/virtualServer": { - "vsInfo": { - "app": "app.app" - }, + "/Common/app.app/virtualServer": "name": "/Common/app.app/virtualServer", - "bits.in": "100" - } + "clientside.bitsIn": "100", + "serviceApp": "app.app" } } @@ -95,9 +88,7 @@ Another example: }, { "setTag": { - "vsInfo": { - "app": "`A`" - } + "serviceApp": "`A`" } }, { @@ -105,9 +96,8 @@ Another example: "locations": { "virtualServers": { ".*": { - "vsInfo": true, "name": true, - "bits.in": true + "clientside.bitsIn": true } } } @@ -149,9 +139,7 @@ Example 1: }, { "setTag": { - "vsInfo": { - "app": "`A`" - } + "serviceApp": "`A`" } }, { @@ -159,9 +147,8 @@ Example 1: "locations": { "virtualServers": { ".*": { - "vsInfo": true, "name": true, - "bits.in": true + "clientside.bitsIn": true } } } @@ -187,9 +174,7 @@ Example 2: }, { "setTag": { - "vsInfo": { - "app": "`A`" - } + "serviceApp": "`A`" }, "ifAllMatch": { "pools": { @@ -204,9 +189,8 @@ Example 2: "locations": { "virtualServers": { ".*": { - "vsInfo": true, "name": true, - "bits.in": true + "clientside.bitsIn": true } } } @@ -308,7 +292,7 @@ The following is a snippet that includes this tagging action. "prop2": "tag1prop2" }, "tag2": "Another tag" - }, + }, "ifAllMatch": { "virtualServers": { ".*": { @@ -541,8 +525,8 @@ The following is an example of Telemetry output without using includeData: }, "virtualServers": { "virtual1": { - "bits.in": "100", - "bits.out": "200" + "clientside.bitsIn": "100", + "clientside.bitsOut": "200" } } } @@ -558,7 +542,7 @@ This is an example of an includeData Action definition: "locations": { "virtualServers": { ".*": { - "bits.in": true + "clientside.bitsIn": true } } } @@ -573,7 +557,7 @@ And this is an example of the output from the Action definition. { "virtualServers": { "virtual1": { - "bits.in": "100" + "clientside.bitsIn": "100" } } } @@ -743,8 +727,8 @@ The following is an example of Telemetry output without using excludeData: }, "virtualServers": { "virtual1": { - "bits.in": "100", - "bits.out": "200" + "clientside.bitsIn": "100", + "clientside.bitsOut": "200" } } } @@ -760,7 +744,7 @@ This is an example of an excludeData Action definition: "locations": { "virtualServers": { ".*": { - "bits.in": true + "clientside.bitsIn": true } } } @@ -781,7 +765,7 @@ And this is an example of the output from the Action definition. }, "virtualServers": { "virtual1": { - "bits.out": "200" + "clientside.bitsOut": "200" } } } diff --git a/docs/faq.rst b/docs/faq.rst index 3cf8e8a5..c43fc38a 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -42,7 +42,7 @@ F5 BIG-IP Telemetry Streaming is available on |github| and is F5-supported. **Which TMOS versions does F5 BIG-IP Telemetry Streaming support?** -F5 BIG-IP Telemetry Streaming supports TMOS 13.x and later. +F5 BIG-IP Telemetry Streaming supports TMOS 15.1.x and later. | @@ -69,7 +69,7 @@ F5 BIG-IP Telemetry Streaming releases are intended to be delivered on a 6-week **What if I upgrade my BIG-IP system, how to I migrate my F5 BIG-IP Telemetry Streaming configuration?** -When you upgrade your BIG-IP system, you simply install F5 BIG-IP Telemetry Streaming on the upgraded BIG-IP system and re-deploy your declaration. For example, you installed BIG-IP Telemetry Streaming on your BIG-IP running version 13.1 and deployed a declaration. You decide to upgrade your BIG-IP system to 14.1. Once the upgrade to 14.1 is complete, you must install BIG-IP Telemetry Streaming on the BIG-IP. After you install BIG-IP Telemetry Streaming, you send the same declaration you used pre-upgrade to the 13.1 BIG-IP system. Your upgraded BIG-IP will then have the same configuration as the previous version. +When you upgrade your BIG-IP system, you simply install F5 BIG-IP Telemetry Streaming on the upgraded BIG-IP system and re-deploy your declaration. For example, you installed BIG-IP Telemetry Streaming on your BIG-IP running version 14.1 and deployed a declaration. You decide to upgrade your BIG-IP system to 15.1. Once the upgrade to 15.1 is complete, you must install BIG-IP Telemetry Streaming on the BIG-IP. After you install BIG-IP Telemetry Streaming, you send the same declaration you used pre-upgrade to the 15.1 BIG-IP system. Your upgraded BIG-IP will then have the same configuration as the previous version. | diff --git a/docs/installation.rst b/docs/installation.rst index b31eb5b2..30629e3c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -22,17 +22,16 @@ Installing F5 BIG-IP Telemetry Streaming using the BIG-IP Configuration utility From the Configuration utility: -1. If you are using a BIG-IP version prior to 14.0, before you can use the Configuration utility, you must enable the framework using the BIG-IP command line. From the CLI, type the following command: ``touch /var/config/rest/iapps/enable``. You only need to run this command once (per BIG-IP system). -2. Click **iApps > Package Management LX**. Your BIG-IP Telemetry Streaming version number may be different than the one shown in the following example. +1. Click **iApps > Package Management LX**. Your BIG-IP Telemetry Streaming version number may be different than the one shown in the following example. .. image:: /images/install1.png -3. Click the **Import** button. +2. Click the **Import** button. .. image:: /images/install2.png -4. Click **Choose File** and then browse to the location you saved the RPM file, and then click **Ok**. -5. Click the **Upload** button. +3. Click **Choose File** and then browse to the location you saved the RPM file, and then click **Ok**. +4. Click the **Upload** button. .. image:: /images/install3.png diff --git a/docs/memory-monitor.rst b/docs/memory-monitor.rst index 506e3a92..d489d7ee 100644 --- a/docs/memory-monitor.rst +++ b/docs/memory-monitor.rst @@ -1,6 +1,6 @@ .. _memorymanagement: -Memory Mamangement - BETA +Memory Management - BETA ========================== .. NOTE:: Using F5 BIG-IP Telemetry Streaming **Memory Monitor** is supported as of BIG-IP TS 1.35. @@ -42,7 +42,7 @@ The "memoryMonitor" property of Controls class is where you define your memory u * - **provisionedMemory** - No - - Defines the total amount of memory available for application. The **allowed** amount of memory is calculated by multiplying **provisionedMemory** and **memoryThresholdPercent**. The default is **1400** MB. The minimal value is **1** and maximum **1400**. + - Defines the total amount of memory available for application. The **allowed** amount of memory is calculated by multiplying **provisionedMemory** and **memoryThresholdPercent**. Defaults to the value of **runtime.maxHeapSize**. The minimal value is **1** and maximum should not exceed **runtime.maxHeapSize**. * - **thresholdReleasePercent** - No @@ -222,6 +222,9 @@ will be enabled and back to its activity. .. NOTE:: It is not recommended to set **thresholdReleasePercent** to **100** because it may result in **flapping** behavior: processing state will switch its states rapidly without a delay. +| + +.. _runtimeconfigoptions: Runtime Configuration options - BETA ------------------------------------ @@ -229,7 +232,7 @@ The "runtime" property of Controls class is where you define your runtime config .. NOTE:: Using F5 BIG-IP Telemetry Streaming **runtime** is supported as of BIG-IP TS 1.35 (currently experimental). -.. IMPORTANT:: **THOSE CONFIGURATION OPTIONS SHOULD BE USED ONLY WHEN YOU ARE OBSERVING/EXPERIENCING MEMORY USAGE ISSUES** +.. IMPORTANT:: **RUNTIME CONFIGURATION OPTIONS SHOULD BE USED ONLY WHEN YOU ARE OBSERVING/EXPERIENCING MEMORY USAGE ISSUES** .. list-table:: :widths: 25 25 200 @@ -242,10 +245,14 @@ The "runtime" property of Controls class is where you define your runtime config * - **enableGC** - No - **EXPERIMENTAL**: Enables the built-in Garbage Collector and makes it available for F5 BIG-IP Telemetry Streaming to clean up freed memory blocks. The default is **false**. + + * - **httpTimeout** + - No + - **EXPERIMENTAL**: Defines the HTTP timeout value in seconds for F5 BIG-IP Telemetry Streaming incoming REST API requests. Allows F5 BIG-IP Telemetry Streaming to avoid TimeoutException error for long lasting operations. The default value set to **60** seconds. The minimal value is **60** seconds and the maximum value is **600**. * - **maxHeapSize** - No - - **EXPERIMENTAL**: Defines the upper limit of V8's heap size that allows F5 BIG-IP Telemetry Streaming to utilize more memory before being killed due to a Heap-Out-Of-Memory error. The default value set to **1400** seconds. The minimal value is **1400**. + - **EXPERIMENTAL**: Defines the upper limit of V8's heap size that allows F5 BIG-IP Telemetry Streaming to utilize more memory before being killed due to a Heap-Out-Of-Memory error. The default value set to **1400** MB. The minimal value is **1400** MB. .. IMPORTANT:: Changes in the runtime's configuration may require the **restnoded** service to be restarted. F5 BIG-IP Telemetry Streaming will schedule the **restnoded** restart when changes in configuration are made. @@ -260,10 +267,6 @@ The good starting point of using **runtime** may looks like following: "runtime": { "enableGC": true } - }, - "listener": { - "class": "Telemetry_Listener", - "enable": false } } @@ -279,11 +282,8 @@ Declaration with all **runtime** properties specified: "class": "Controls", "runtime": { "enableGC": false, + "httpTimeout": 60, "maxHeapSize": 1400 } - }, - "listener": { - "class": "Telemetry_Listener", - "enable": false } } diff --git a/docs/prereqs.rst b/docs/prereqs.rst index 610a80c3..a04ba486 100644 --- a/docs/prereqs.rst +++ b/docs/prereqs.rst @@ -3,9 +3,10 @@ Prerequisites and Requirements The following are prerequisites for using F5 BIG-IP Telemetry Streaming: -- You must be using BIG-IP version 13.1 or later to use F5 BIG-IP Telemetry Streaming. +- You must be using BIG-IP version 15.1 or later to use F5 BIG-IP Telemetry Streaming. F5 BIG-IP Telemetry Streaming is not intended to work on BIG-IP versions that have reached End of Life. See `here `_ for more information about BIG-IP versions supported by F5. +.. IMPORTANT:: Beginning with BIG-IP Telemetry Streaming version 1.36.0 BIG-IP Telemetry Streaming no longer supports BIG-IP 13.1 to 15.0.x. However, if you are still using the BIG-IP 13.1 to 15.0.x versions, you can use BIG-IP Telemetry Streaming 1.35.0 or earlier. - To install and configure F5 BIG-IP Telemetry Streaming, your BIG-IP user account must have the **Administrator** role. @@ -14,4 +15,4 @@ The following are prerequisites for using F5 BIG-IP Telemetry Streaming: - You must have a configured consumer that will receive the data. - You should be familiar with the F5 BIG-IP and F5 terminology. For general information and documentation on the BIG-IP system, see the - `F5 Knowledge Center `_. + `F5 Knowledge Center `_. diff --git a/docs/quick-start.rst b/docs/quick-start.rst index 00ee7981..0f3bb044 100644 --- a/docs/quick-start.rst +++ b/docs/quick-start.rst @@ -5,10 +5,6 @@ If you are familiar with the BIG-IP system, and generally familiar with REST and using APIs, this section contains the minimum amount of information to get you up and running with F5 BIG-IP Telemetry Streaming. -.. sidebar:: :fonticon:`fa fa-info-circle fa-lg` Version Notice: - - In BIG-IP versions prior to 14.0.0, the Package Management LX tab will not show up in the user interface unless you run the following command from the BIG-IP CLI: ``touch /var/config/rest/iapps/enable``. - #. Download the latest RPM package from the |github|. #. Upload and install the RPM package on the using the BIG-IP GUI: diff --git a/docs/revision-history.rst b/docs/revision-history.rst index 95fc21d4..90edc6db 100644 --- a/docs/revision-history.rst +++ b/docs/revision-history.rst @@ -21,9 +21,13 @@ There is no plan to deprecate this product. - Description - Date + * - 1.36.0 + - Updated the documentation for Telemetry Streaming v1.36.0. This release contains the following changes: |br| * Drop support for BIG-IP 13.1 to 15.0.x + - 06-10-24 + * - 1.35.0 - - Updated the documentation for Telemetry Streaming v1.35.0. This release contains the following changes: |br| * Added "memoryMonitor" (see :ref:`Memory Mamangement`). - - 01-19-24 + - Updated the documentation for Telemetry Streaming v1.35.0. This release contains the following changes: |br| * Added "memoryMonitor" (see :ref:`Memory Mamangement`). |br| |br| Changed: * Event Listener performance improvements + - 02-23-24 * - 1.34.0 - Updated the documentation for Telemetry Streaming v1.34.0. This release contains the following changes: |br| * Added "verbose" option for "logLevel". |br| * Event Listener bugfixes, performance and memory usage improvements. |br| * Updated description for "default" consumers. |br| * More troubleshooting entries. |br| |br| Changed: |br| * Update npm packages diff --git a/docs/telemetry-system.rst b/docs/telemetry-system.rst index de699dfe..4505f3f9 100644 --- a/docs/telemetry-system.rst +++ b/docs/telemetry-system.rst @@ -24,12 +24,30 @@ The system poller collects and normalizes statistics from a system, such as BIG- } } -+--------------------+--------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------+ -| Parameter | Options | Description/Notes | -+====================+================================+============================================================================================================================================+ -| interval | 0, 60 - 6000, **300** | This value determines the polling period in seconds. By default, Telemetry Streaming collects statistics every 300 seconds. | -| | | When value set to 0 then interval polling is disabled, useful when you want to configure :ref:`pullconsumer-ref` | -+--------------------+--------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------+ ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| Parameter | Options | Description/Notes | ++============================+================================+=================================================================================================================================================================================+ +| class | Telemetry_System | String: The class for the Telemetry System must always be ``Telemetry_System``, do not change this value. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| enable | **true**, false | Boolean: this value enables or disables the Telemetry System and all pollers attached to it. By default it is set to ``true``. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| trace | true, **false** | Boolean: this value enables or disables tracing. By default it is set to ``false``. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| username | | **Optional**. String: F5 BIG-IP username. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| passphrase | | **Optional**. Object: F5 BIG-IP password. Requires ``username`` to be set. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| host | | **Optional**. String: F5 BIG-IP hostname or IP address. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| port | | **Required**. Number: connection port. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| protocol | | **Required**. String: HTTP protocol. Allowed values are ``http`` or ``https``. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| allowSelfSignedCert | | **Required**. String: Allow to use self-signed certificates to establish HTTP connection. By default it is set to ``false``. | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| interval | 0, 60 - 6000, **300** | This value determines the polling period in seconds. By default, Telemetry Streaming collects statistics every 300 seconds. | +| | | When value set to 0 then interval polling is disabled, useful when you want to configure :ref:`pullconsumer-ref` | ++----------------------------+--------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ .. _ihealthpoller: @@ -39,6 +57,8 @@ The iHealth poller creates a QKView on the target system, downloads it to the ho .. IMPORTANT:: The iHealth Poller must be attached to a Telemetry_System class, otherwise the iHealth Poller will be disabled. +.. IMPORTANT:: Beginning with BIG-IP Telemetry Streaming version 1.36.0 BIG-IP Telemetry Streaming requires users to follow instructions described in `K000135241 `_ to obtain credentials to access F5 iHealth Service API. + iHealth Poller minimal declaration: .. code-block:: javascript @@ -48,9 +68,9 @@ iHealth Poller minimal declaration: "My_System_Minimal": { "class": "Telemetry_System", "iHealthPoller": { - "username": "username", + "username": "test_username_id", "passphrase": { - "cipherText": "passphrase" + "cipherText": "test_passphrase_id" }, "interval": { "timeWindow": { @@ -69,17 +89,17 @@ iHealth Poller full declaration: "My_System_Minimal": { "class": "Telemetry_System", "iHealthPoller": { - "username": "username", + "username": "test_username_id", "passphrase": { - "cipherText": "passphrase" + "cipherText": "test_passphrase_id" }, "proxy": { "host": "127.0.0.1", "protocol": "http", "port": 80, - "username": "username", + "username": "test_username_proxy", "passphrase": { - "cipherText": "passphrase" + "cipherText": "test_passphrase_proxy" } }, "interval": { @@ -97,15 +117,15 @@ iHealth Poller full declaration: +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Parameter | Options | Description/Notes | +============================+================================+===================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+ -| class | Telemetry_iHealth_Poller | String: The class for the iHealth poller must always be Telemetry_iHealth_Poller, do not change this value. | +| class | Telemetry_iHealth_Poller | String: The class for the iHealth poller must always be ``Telemetry_iHealth_Poller``, do not change this value. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | enable | **true**, false | Boolean: this value enables or disables the iHealth Poller. By default it is set to ``true``. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | trace | true, **false** | Boolean: this value enables or disables tracing. By default it is set to ``false``. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| username | | **Required**. String: use your F5 iHealth Service Account username. | +| username | | **Required**. String: use your F5 iHealth Service Client ID. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| passphrase | | **Required**. Object: use your F5 iHealth Service Account passphrase. | +| passphrase | | **Required**. Object: use your F5 iHealth Service Client Secret. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | proxy | | **Optional**. Object: this value allows you to send QKView to F5 iHealth Service via proxy. | +----------------------------+--------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index cd537470..be5e7187 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -447,7 +447,7 @@ F5 BIG-IP Telemetry Streaming 1.19 and later includes the **compressionType** pr Why is BIG-IP TS not showing up in UCS archive? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Due the fact that F5 BIG-IP TS has a quite high number of dependencies and sub-dependecies the BIG-IP unable to include it to UCS archive. You may see following log entries in **/var/log/ltm**: +Due the fact that F5 BIG-IP Telemetry Streaming has a quite high number of dependencies and sub-dependecies the BIG-IP unable to include it to UCS archive. You may see following log entries in **/var/log/ltm**: .. code-block:: bash @@ -486,7 +486,7 @@ For more information see `K51300313 `_ for more info. + +For more information see: + +- :ref:`runtimeconfigoptions` + +- `K26408354 `_ + +- `K94602685 `_ + +- `Bug ID 858189 `_ + +- `Bug ID 1602033 `_ + + .. |br| raw:: html
diff --git a/examples/declarations/System/system.json b/examples/declarations/System/system.json index 7eb37773..bbd831b5 100644 --- a/examples/declarations/System/system.json +++ b/examples/declarations/System/system.json @@ -16,7 +16,13 @@ "systemPoller": { "enable": true, "trace": false, - "interval": 60 + "interval": 60, + "httpAgentOpts": [ + { "name": "keepAlive", "value": true }, + { "name": "keepAliveMsecs", "value": 600000 }, + { "name": "maxFreeSockets", "value": 5 }, + { "name": "maxSockets", "value": 5 } + ] }, "iHealthPoller": { "username": "username", diff --git a/examples/declarations/all_properties.json b/examples/declarations/all_properties.json index 90e80256..67fd9223 100644 --- a/examples/declarations/all_properties.json +++ b/examples/declarations/all_properties.json @@ -15,6 +15,7 @@ }, "runtime": { "enableGC": true, + "httpTimeout": 60, "maxHeapSize": 1400 } }, diff --git a/examples/declarations/consumers/Kafka/kafka.json b/examples/declarations/consumers/Kafka/kafka.json index cc7f4234..4bddafd7 100644 --- a/examples/declarations/consumers/Kafka/kafka.json +++ b/examples/declarations/consumers/Kafka/kafka.json @@ -1,9 +1,9 @@ { "class": "Telemetry", - "My_Consumer": { + "My_Consumer_Default_Format": { "class": "Telemetry_Consumer", "type": "Kafka", - "host": "192.168.2.1", + "host": "192.168.3.1", "protocol": "binaryTcpTls", "port": 9092, "topic": "f5-telemetry" @@ -11,7 +11,7 @@ "My_Consumer_SASL_PLAIN_auth": { "class": "Telemetry_Consumer", "type": "Kafka", - "host": "192.168.2.1", + "host": ["192.168.2.1", "192.168.2.2"], "protocol": "binaryTcpTls", "port": 9092, "topic": "f5-telemetry", @@ -19,12 +19,18 @@ "username": "username", "passphrase": { "cipherText": "passphrase" - } + }, + "format": "split", + "partitionerType": "random", + "customOpts": [ + { "name": "maxAsyncRequests", "value": 30 }, + { "name": "connectRetryOptions.retries", "value": 10} + ] }, "My_Consumer_TLS_client_auth": { "class": "Telemetry_Consumer", "type": "Kafka", - "host": "kafka.example.com", + "host": ["kafka.example.com"], "protocol": "binaryTcpTls", "port": 9092, "topic": "f5-telemetry", @@ -37,6 +43,9 @@ }, "rootCertificate": { "cipherText": "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----" - } + }, + "format": "split", + "partitionerType": "keyed", + "partitionKey": "p1" } } \ No newline at end of file diff --git a/examples/declarations/consumers/Splunk/splunk_http_agent.json b/examples/declarations/consumers/Splunk/splunk_http_agent.json new file mode 100644 index 00000000..189ae892 --- /dev/null +++ b/examples/declarations/consumers/Splunk/splunk_http_agent.json @@ -0,0 +1,20 @@ +{ + "class": "Telemetry", + "My_Consumer": { + "class": "Telemetry_Consumer", + "type": "Splunk", + "host": "192.168.2.1", + "protocol": "https", + "port": 8088, + "passphrase": { + "cipherText": "apikey" + }, + "compressionType": "gzip", + "customOpts": [ + { "name": "keepAlive", "value": true }, + { "name": "keepAliveMsecs", "value": 600000 }, + { "name": "maxFreeSockets", "value": 5 }, + { "name": "maxSockets", "value": 5 } + ] + } +} \ No newline at end of file diff --git a/examples/declarations/iHealth_Poller/ihealth_all.json b/examples/declarations/iHealth_Poller/ihealth_all.json index 6644330c..0976df12 100644 --- a/examples/declarations/iHealth_Poller/ihealth_all.json +++ b/examples/declarations/iHealth_Poller/ihealth_all.json @@ -53,7 +53,7 @@ "end": "03:15" } }, - "downloadFolder": "example_download_folder" + "downloadFolder": "/example_download_folder" }, "Daily_iHealth": { "class": "Telemetry_iHealth_Poller", diff --git a/package-lock.json b/package-lock.json index 8feb9bce..da1f28b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "f5-telemetry", - "version": "1.35.0-1", + "version": "1.36.0-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "f5-telemetry", - "version": "1.35.0-0", + "version": "1.36.0-0", "license": "Apache-2.0", "dependencies": { "@f5devcentral/f5-teem": "^1.6.1", @@ -21,150 +21,101 @@ "ajv-keywords": "^3.5.2", "applicationinsights": "^1.8.10", "aws-sdk": "2.1018.0", - "eventemitter2": "^6.4.9", + "eventemitter2": "6.4.9", "google-auth-library": "^6.1.6", + "heap": "^0.2.7", "jmespath": "^0.16.0", "json-duplicate-key-handle": "file:opensource/json-duplicate-key-handle", - "jsonwebtoken": "^8.5.1", - "kafka-node": "^2.6.1", + "jsonwebtoken": "^8", + "kafka-node": "^5.0.0", "lodash": "^4.17.21", "machina": "^4.0.2", "mustache": "^4.2.0", + "pako": "^1.0.11", "prom-client": "11.0.0", "request": "^2.88.2", "statsd-client": "^0.4.7", "tiny-request-router": "^1.2.2", - "uuid": "^3.4.0" + "uuid": "^9" }, "devDependencies": { "@f5devcentral/eslint-config-f5-atg": "^0.1.8", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "deep-diff": "^1.0.2", - "eslint": "^8.45.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.27.5", + "eslint-plugin-import": "^2.29.1", "icrdk": "git+https://git@github.com/f5devcentral/f5-icontrollx-dev-kit.git#master", - "memfs": "3.5.0", - "mocha": "^5.2.0", - "nock": "10.0.0", + "memfs": "^4", + "mocha": "^7.2.0", + "nock": "^11", "nyc": "^14.1.1", - "object.values": "^1.1.6", "proxyquire": "^2.1.3", - "sinon": "^7.5.0", + "sinon": "^15", "ssh2": "^0.8.9", - "winston": "^2.4.7" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, + "winston": "^2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8.11.1" } }, "node_modules/@babel/code-frame": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", - "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.5" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/generator": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.9.tgz", - "integrity": "sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.25.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz", - "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" + "@babel/helper-validator-identifier": "^7.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -242,10 +193,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.7.tgz", - "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", "dev": true, + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -254,34 +208,31 @@ } }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.8.tgz", - "integrity": "sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/generator": "^7.22.7", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.7", - "@babel/types": "^7.22.5", - "debug": "^4.1.0", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", + "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -298,47 +249,32 @@ } }, "node_modules/@babel/types": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz", - "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, "node_modules/@eslint-community/regexpp": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.0.tgz", - "integrity": "sha512-uiPeRISaglZnaZk8vwrjQZ1CxogZeY/4IYft6gBOTqu1WhVXWmCmZMWxUv2Q/pxSvPdp1JPaO62kLOcOkMqWrw==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz", - "integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -359,9 +295,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.44.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz", - "integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -377,9 +313,9 @@ } }, "node_modules/@f5devcentral/atg-storage": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@f5devcentral/atg-storage/-/atg-storage-1.3.9.tgz", - "integrity": "sha512-50+q1gHlGSoCmsey+lrMbcW3NnWoiUv3aYPqAgVHBWAf3OXqOG8ppUJ8hC2RsOpevOkxuHDs1q5VQDBeOwY0Gg==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@f5devcentral/atg-storage/-/atg-storage-1.3.10.tgz", + "integrity": "sha512-vgk2B/INrOlxBGyfrOqn2hDPPfAofeeZpMMBAkaXU1tA18VU7erZ202OJ5Yw9ZvUYEwoda2crPIR4vwL+AhzEw==", "dependencies": { "@f5devcentral/atg-shared-utilities": "^0.6.0" } @@ -406,27 +342,35 @@ "uuid": "^3.4.0" } }, + "node_modules/@f5devcentral/f5-teem/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/@grpc/grpc-js": { - "version": "1.8.18", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.8.18.tgz", - "integrity": "sha512-2uWPtxhsXmVgd8WzDhfamSjHpZDXfMjMDciY6VRTq4Sn7rFzazyf0LLDa0oav+61UHIoEZb4KKaAV6S7NuJFbQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.11.1.tgz", + "integrity": "sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw==", "dependencies": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=12.10.0" } }, "node_modules/@grpc/proto-loader": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.8.tgz", - "integrity": "sha512-GU12e2c8dmdXb7XUlOgYWZ2o2i+z9/VeACkxTA/zzAe2IjclC5PnVL0lpgjhrqfpDYHzM8B1TF6pqWegMYAzlA==", + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", "dependencies": { - "@types/long": "^4.0.1", "lodash.camelcase": "^4.3.0", - "long": "^4.0.0", - "protobufjs": "^7.2.4", + "long": "^5.0.0", + "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { @@ -437,13 +381,14 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", - "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -464,64 +409,122 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.0.tgz", + "integrity": "sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==", + "dev": true, + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", + "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -567,24 +570,22 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.41.0.tgz", - "integrity": "sha512-kopW4ZEKX2mgaPi9jh3lTP+2ixbe0z+tAEOn3v0ZM6jzQl7z+2C1ZZjU1cVYbX+RDGqu7n6BMyv5wmWuqiuKYQ==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.41.2.tgz", + "integrity": "sha512-JEV2RAqijAFdWeT6HddYymfnkiRu2ASxoTBr4WsnGJhOjWZkEy6vp+Sx9ozr1NaIODOa2HUyckExIqQjn6qywQ==", "dependencies": { - "@opentelemetry/api": "^1.0.0", - "tslib": "^2.3.1" + "@opentelemetry/api": "^1.0.0" }, "engines": { "node": ">=14" } }, "node_modules/@opentelemetry/core": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.0.tgz", - "integrity": "sha512-GGTS6BytfaN8OgbCUOnxg/a9WVsVUj0484zXHZuBzvIXx7V4Tmkb0IHnnhS7Q0cBLNLgjNuvrCpQaP8fIvO4bg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.2.tgz", + "integrity": "sha512-+gBv15ta96WqkHZaPpcDHiaz0utiiHZVfm2YOYSqFGrUaJpPkMoSuLBB58YFQGi6Rsb9EHos84X6X5+9JspmLw==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/semantic-conventions": "1.15.2" }, "engines": { "node": ">=14" @@ -594,18 +595,17 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.41.0.tgz", - "integrity": "sha512-IVLf07OTFmPs6SwViYNBGPTnOGN2gDLhQiw/O60m7CBvBOfEfcg83w/bVF4Va3m6H5cReVbQsKEx+AaCVl6smg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.41.2.tgz", + "integrity": "sha512-gQuCcd5QSMkfi1XIriWAoak/vaRvFzpvtzh2hjziIvbnA3VtoGD3bDb2dzEzOA1iSWO0/tHwnBsSmmUZsETyOA==", "dependencies": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.41.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/exporter-metrics-otlp-http": "0.41.2", + "@opentelemetry/otlp-grpc-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" }, "engines": { "node": ">=14" @@ -614,17 +614,32 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "dependencies": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.41.0.tgz", - "integrity": "sha512-YttGW1XEHB9GocXtEY+n0qAT2Ewi/P4l7882kYK4kEl78EAnVvvWvFX1El+TvHA3D2LHDxx9ASu1i+icCqj/Fw==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.41.2.tgz", + "integrity": "sha512-+YeIcL4nuldWE89K8NBLImpXCvih04u1MBnn8EzvoywG2TKR5JC3CZEPepODIxlsfGSgP8W5khCEP1NHZzftYw==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" }, "engines": { "node": ">=14" @@ -633,19 +648,34 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "dependencies": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.41.0.tgz", - "integrity": "sha512-6cQlyV/cQk/kzyI/u/eoAOjtQO+SkWUJbnyI1nWGYADwtbJtJ4sl6ks7t7cdppTr7/66fMgXVKIIjjPowoEcGw==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.41.2.tgz", + "integrity": "sha512-OLNs6wF84uhxn8TJ8Bv1q2ltdJqjKA9oUEtICcUDDzXIiztPxZ9ur/4xdMk9T3ZJeFMfrhj8eYDkpETBy+fjCg==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.41.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "@opentelemetry/otlp-proto-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/exporter-metrics-otlp-http": "0.41.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "@opentelemetry/otlp-proto-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" }, "engines": { "node": ">=14" @@ -654,13 +684,28 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "dependencies": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.41.0.tgz", - "integrity": "sha512-fSHtZznIU6kvCLFQC77nOhHj059G1sc/wNl96YiPdro4A8t8ue//ET0yAtpRCQ9lynn4RNrpsw5iEFJszEbmLg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.41.2.tgz", + "integrity": "sha512-pfwa6d+Dax3itZcGWiA0AoXeVaCuZbbqUTsCtOysd2re8C2PWXNxDONUfBWsn+KgxAdi+ljwTjJGiaVLDaIEvQ==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2" }, "engines": { "node": ">=14" @@ -670,15 +715,14 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.41.0.tgz", - "integrity": "sha512-TdbZ46i2kKeGKE9SCZFiSt1iTLHS+DniEaWbVsIhEPOLZXl8TGzzi1FjR/Q3gG/vlblYZ/MdgXHgRIGVG5qIDw==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.41.2.tgz", + "integrity": "sha512-OErK8dYjXG01XIMIpmOV2SzL9ctkZ0Nyhf2UumICOAKtgLvR5dG1JMlsNVp8Jn0RzpsKc6Urv7JpP69wzRXN+A==", "dependencies": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "protobufjs": "^7.2.3", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "protobufjs": "^7.2.3" }, "engines": { "node": ">=14" @@ -688,14 +732,13 @@ } }, "node_modules/@opentelemetry/otlp-proto-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-proto-exporter-base/-/otlp-proto-exporter-base-0.41.0.tgz", - "integrity": "sha512-VY/7y8ne72PIzPxFN3uzHfrmxo9rCDWP08/fY3iodjizCxmCCRFM4Sb7VX0ZSrjakL1mLXFd0FSwe71AsAtM9A==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-proto-exporter-base/-/otlp-proto-exporter-base-0.41.2.tgz", + "integrity": "sha512-BxmEMiP6tHiFroe5/dTt9BsxCci7BTLtF7A6d4DKHLiLweWWZxQ9l7hON7qt/IhpKrQcAFD1OzZ1Gq2ZkNzhCw==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "protobufjs": "^7.2.3", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "protobufjs": "^7.2.3" }, "engines": { "node": ">=14" @@ -705,17 +748,32 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.41.0.tgz", - "integrity": "sha512-a5GqVSdVIhAoYcQrdWQAeMbrkz0iDwKC6BUsuqPuykh+T4QZzrF6cwneOXKbQI5Dl7ms6ha9dYHf4Ka0kc66ZQ==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.41.2.tgz", + "integrity": "sha512-jJbPwB0tNu2v+Xi0c/v/R3YBLJKLonw1p+v3RVjT2VfzeUyzSp/tBeVdY7RZtL6dzZpA9XSmp8UEfWIFQo33yA==", + "dependencies": { + "@opentelemetry/api-logs": "0.41.2", + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-logs": "0.41.2", + "@opentelemetry/sdk-metrics": "1.15.2", + "@opentelemetry/sdk-trace-base": "1.15.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", "dependencies": { - "@opentelemetry/api-logs": "0.41.0", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-logs": "0.41.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "@opentelemetry/sdk-trace-base": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" }, "engines": { "node": ">=14" @@ -725,13 +783,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.15.0.tgz", - "integrity": "sha512-Sb8A6ZXHXDlgHv32UNRE3y8McWE3vkb5dsSttYArYa5ZpwjiF5ge0vnnKUUnG7bY0AgF9VBIOORZE8gsrnD2WA==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.15.2.tgz", + "integrity": "sha512-xmMRLenT9CXmm5HMbzpZ1hWhaUowQf8UB4jMjFlAxx1QzQcsD3KFNAVX/CAWzFPtllTyTplrA4JrQ7sCH3qmYw==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/semantic-conventions": "1.15.2" }, "engines": { "node": ">=14" @@ -741,13 +798,12 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.41.0.tgz", - "integrity": "sha512-+Qs8uHcd/tYKS1n6lfSPiQXMOuyPN0c3xKeyWjD5mExRvmA1H6SIYfZmB6KeQNXWODK4z4JtWo5g5Efe0gJ1Vg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.41.2.tgz", + "integrity": "sha512-smqKIw0tTW15waj7BAPHFomii5c3aHnSE4LQYTszGoK5P9nZs8tEAIpu15UBxi3aG31ZfsLmm4EUQkjckdlFrw==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2" }, "engines": { "node": ">=14" @@ -758,31 +814,66 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.0.tgz", - "integrity": "sha512-fFUnAcPvlXO39nlIduGuaeCuiZyFtSLCn9gW/0djFRO5DFst4m4gcT6+llXvNWuUvtGB49s56NP10B9IZRN0Rw==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", + "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "lodash.merge": "^4.6.2", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.25.1", + "@opentelemetry/resources": "1.25.1", + "lodash.merge": "^4.6.2" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.5.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", + "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.25.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", + "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", + "dependencies": { + "@opentelemetry/core": "1.25.1", + "@opentelemetry/semantic-conventions": "1.25.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", + "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", + "engines": { + "node": ">=14" } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.15.0.tgz", - "integrity": "sha512-udt1c9VHipbZwvCPIQR1VLg25Z4AMR/g0X8KmcInbFruGWQ/lptVPkz3yvWAsGSta5yHNQ3uoPwcyCygGnQ6Lg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.15.2.tgz", + "integrity": "sha512-BEaxGZbWtvnSPchV98qqqqa96AOcb41pjgvhfzDij10tkBhIu9m0Jd6tZ1tJB5ZHfHbTffqYVYE0AOGobec/EQ==", "dependencies": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/semantic-conventions": "1.15.2" }, "engines": { "node": ">=14" @@ -792,12 +883,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.0.tgz", - "integrity": "sha512-f3wwFrFyCpGrFBrFs7lCUJSCSCGyeKG52c+EKeobs3Dd29M75yO6GYkt6PkYPfDawxSlV5p+4yJPPk8tPObzTQ==", - "dependencies": { - "tslib": "^2.3.1" - }, + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.2.tgz", + "integrity": "sha512-CjbOKwk2s+3xPIMcd5UNYQzsf+v94RczbdNix9/kQh38WiQkM90sUOi3if8eyHFgiBjBjhwXrA7W3ydiSQP9mw==", "engines": { "node": ">=14" } @@ -857,33 +945,59 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "dependencies": { "type-detect": "4.0.8" } }, - "node_modules/@sinonjs/formatio": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz", - "integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==", + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "dependencies": { - "@sinonjs/commons": "^1", - "@sinonjs/samsam": "^3.1.0" + "@sinonjs/commons": "^3.0.0" } }, "node_modules/@sinonjs/samsam": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz", - "integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", "dev": true, "dependencies": { - "@sinonjs/commons": "^1.3.0", - "array-from": "^2.1.1", - "lodash": "^4.17.15" + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" } }, "node_modules/@sinonjs/text-encoding": { @@ -898,15 +1012,19 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, "node_modules/@types/node": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.4.tgz", - "integrity": "sha512-CukZhumInROvLq3+b5gLev+vgpsIqC2D0deQr/yS1WnxvmYLlJXZpaQrQiseMY+6xusl79E04UjWoqyr+t1/Ew==" + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", + "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==", + "dependencies": { + "undici-types": "~6.13.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true }, "node_modules/abort-controller": { "version": "3.0.0", @@ -920,9 +1038,9 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -983,6 +1101,15 @@ "node": "*" } }, + "node_modules/ansi-colors": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", + "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-gray": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", @@ -1026,6 +1153,19 @@ "node": ">=0.10.0" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/append-transform": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", @@ -1065,6 +1205,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", "optional": true, "dependencies": { "delegates": "^1.0.0", @@ -1105,13 +1246,16 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1135,22 +1279,17 @@ "node": ">=0.10.0" } }, - "node_modules/array-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", - "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==", - "dev": true - }, "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" }, "engines": { @@ -1187,15 +1326,35 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -1206,14 +1365,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -1223,17 +1382,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.reduce": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", + "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.1.tgz", - "integrity": "sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -1334,11 +1516,14 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, - "engines": { + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { "node": ">= 0.4" }, "funding": { @@ -1391,9 +1576,9 @@ } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.1.tgz", + "integrity": "sha512-u5w79Rd7SU4JaIlA/zFqG+gOiuq25q5VLyZ8E+ijJeILuTxVzZgp2CaGw/UTw6pXYN9XMO9yiqj/nEHmhTG5CA==" }, "node_modules/backslash": { "version": "0.2.0", @@ -1472,9 +1657,9 @@ } }, "node_modules/bignumber.js": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", - "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", "engines": { "node": "*" } @@ -1491,6 +1676,18 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1506,9 +1703,9 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, "node_modules/bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", "dependencies": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -1641,9 +1838,9 @@ } }, "node_modules/bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.4.0.tgz", + "integrity": "sha512-sWm8iPbqvL9+5SiYxXH73UOkyEbGQg7kyHQmReF89WJHQJw2eV4P/yZ0E+b71cczJ4pPobVhXxgQcmfSTgGHxQ==", "optional": true, "dependencies": { "ansi-color": "^0.2.1", @@ -1691,13 +1888,19 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1727,33 +1930,33 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, "node_modules/chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" } }, "node_modules/chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.2.tgz", + "integrity": "sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==", "dev": true, "dependencies": { "check-error": "^1.0.2" }, "peerDependencies": { - "chai": ">= 2.1.2 < 5" + "chai": ">= 2.1.2 < 6" } }, "node_modules/chainsaw": { @@ -1784,116 +1987,139 @@ } }, "node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, "engines": { "node": "*" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "optional": true - }, - "node_modules/class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "node_modules/chokidar": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", + "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", "dev": true, "dependencies": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.2.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.1.1" } }, - "node_modules/class-utils/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "node_modules/chokidar/node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "is-descriptor": "^0.1.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/class-utils/node_modules/is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", + "node_modules/chokidar/node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { - "kind-of": "^3.0.2" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/class-utils/node_modules/is-accessor-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { - "is-buffer": "^1.1.5" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, - "node_modules/class-utils/node_modules/is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", + "node_modules/chokidar/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/chokidar/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "dependencies": { - "kind-of": "^3.0.2" + "is-number": "^7.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8.0" } }, - "node_modules/class-utils/node_modules/is-data-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true + }, + "node_modules/class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", "dev": true, "dependencies": { - "is-buffer": "^1.1.5" + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/class-utils/node_modules/is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "node_modules/class-utils/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", "dev": true, "dependencies": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-descriptor": "^0.1.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/class-utils/node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "node_modules/class-utils/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/cliui": { @@ -1962,7 +2188,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -2026,10 +2252,13 @@ } }, "node_modules/commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", - "dev": true + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", + "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", + "dev": true, + "engines": { + "node": ">= 0.6.x" + } }, "node_modules/commondir": { "version": "1.0.1", @@ -2038,10 +2267,13 @@ "dev": true }, "node_modules/component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -2139,6 +2371,57 @@ "node": ">=0.10" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dateformat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", @@ -2149,9 +2432,9 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "dependencies": { "ms": "2.1.2" }, @@ -2201,9 +2484,9 @@ "dev": true }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, "dependencies": { "type-detect": "^4.0.0" @@ -2212,23 +2495,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "dev": true, - "dependencies": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -2268,12 +2534,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -2311,6 +2595,14 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "optional": true }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deprecated": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz", @@ -2478,50 +2770,57 @@ } }, "node_modules/es-abstract": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz", - "integrity": "sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.1", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.1", - "get-symbol-description": "^1.0.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "safe-array-concat": "^1.0.0", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.10" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -2530,27 +2829,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "dev": true, "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" } }, "node_modules/es-to-primitive": { @@ -2577,9 +2915,9 @@ "dev": true }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "engines": { "node": ">=6" } @@ -2597,27 +2935,28 @@ } }, "node_modules/eslint": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz", - "integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.1.0", - "@eslint/js": "8.44.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.6.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2679,14 +3018,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", - "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -2699,9 +3038,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", + "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", "dev": true, "dependencies": { "debug": "^3.2.7" @@ -2725,26 +3064,28 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.27.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", - "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", - "eslint-module-utils": "^2.7.4", - "has": "^1.0.3", - "is-core-module": "^2.11.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.6", - "resolve": "^1.22.1", - "semver": "^6.3.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -2784,9 +3125,9 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz", - "integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -2800,9 +3141,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2811,6 +3152,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -2842,9 +3198,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -2955,81 +3311,23 @@ "node": ">=0.10.0" } }, - "node_modules/expand-brackets/node_modules/is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", + "node_modules/expand-brackets/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "dependencies": { - "kind-of": "^3.0.2" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/expand-brackets/node_modules/is-accessor-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/is-data-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "node_modules/expand-brackets/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true, "engines": { "node": ">=0.10.0" @@ -3186,9 +3484,9 @@ "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -3358,13 +3656,26 @@ "node": ">= 0.10" } }, + "node_modules/flat": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", + "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", + "dev": true, + "dependencies": { + "is-buffer": "~2.0.3" + }, + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "dependencies": { - "flatted": "^3.1.0", + "flatted": "^3.2.9", + "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { @@ -3372,9 +3683,9 @@ } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "node_modules/for-each": { @@ -3494,34 +3805,46 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "optional": true }, - "node_modules/fs-monkey": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.4.tgz", - "integrity": "sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==", - "dev": true - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "deprecated": "\"Please update to latest v2.3 or v2.2\"", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -3543,6 +3866,7 @@ "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", "optional": true, "dependencies": { "aproba": "^1.0.3", @@ -3633,37 +3957,42 @@ } }, "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, "engines": { "node": "*" } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -3696,9 +4025,10 @@ "optional": true }, "node_modules/glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -3745,6 +4075,7 @@ "version": "4.5.3", "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz", "integrity": "sha512-I0rTWUKSZKxPSIAIaqhSXTM/DiII6wame+rEC3cFA5Lqmr9YmdL7z6Hj9+bdWtTvoY1Su4/OiMLmb37Y7JzvJQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "inflight": "^1.0.4", @@ -3870,9 +4201,9 @@ } }, "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -3885,12 +4216,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -3917,6 +4249,7 @@ "version": "3.1.21", "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz", "integrity": "sha512-ANhy2V2+tFpRajE3wN4DhkNQ08KDr0Ir1qL12/cUe5+a7STEK8jkW4onUYuY8/06qAFuT5je7mjAqzx0eKI2tQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "graceful-fs": "~1.2.0", @@ -4008,6 +4341,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.1.4.tgz", "integrity": "sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg==", + "deprecated": "Package is no longer maintained", "dependencies": { "node-forge": "^1.3.1" }, @@ -4108,15 +4442,6 @@ "node": ">=0.10.0" } }, - "node_modules/gulp-mocha/node_modules/commander": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", - "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", - "dev": true, - "engines": { - "node": ">= 0.6.x" - } - }, "node_modules/gulp-mocha/node_modules/debug": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", @@ -4148,6 +4473,7 @@ "version": "3.2.11", "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", "integrity": "sha512-hVb0zwEZwC1FXSKRPFTeOtN7AArJcJlI6ULGLtrstaswKNlrTJqAA+1lYlSUop4vjA423xlBzqfVS3iWGlqJ+g==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "inherits": "2", @@ -4183,6 +4509,25 @@ "node": "*" } }, + "node_modules/gulp-mocha/node_modules/minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", + "dev": true + }, + "node_modules/gulp-mocha/node_modules/mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "dev": true, + "dependencies": { + "minimist": "0.0.8" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/gulp-mocha/node_modules/mocha": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", @@ -4426,18 +4771,6 @@ "node": ">=6" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -4490,21 +4823,21 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, "engines": { "node": ">= 0.4" @@ -4526,12 +4859,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -4573,6 +4906,12 @@ "node": ">=0.10.0" } }, + "node_modules/has-values/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "node_modules/has-values/node_modules/kind-of": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", @@ -4606,15 +4945,32 @@ "node": ">=0.10.0" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, "bin": { "he": "bin/he" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "node_modules/hexer": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", @@ -4683,6 +5039,15 @@ "node": ">= 6" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "engines": { + "node": ">=10.18" + } + }, "node_modules/icrdk": { "version": "1.0.2", "resolved": "git+https://git@github.com/f5devcentral/f5-icontrollx-dev-kit.git#b439e63cc8936059f93392e88c3a653a4f823cb3", @@ -4698,9 +5063,9 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" @@ -4744,6 +5109,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "dependencies": { "once": "^1.3.0", @@ -4762,13 +5128,13 @@ "devOptional": true }, "node_modules/internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "hasown": "^2.0.0", "side-channel": "^1.0.4" }, "engines": { @@ -4807,25 +5173,25 @@ } }, "node_modules/is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", "dev": true, "dependencies": { - "kind-of": "^6.0.0" + "hasown": "^2.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -4834,20 +5200,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4866,6 +5218,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -4883,10 +5247,27 @@ } }, "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } }, "node_modules/is-callable": { "version": "1.2.7", @@ -4901,27 +5282,45 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", - "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", + "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", "dev": true, "dependencies": { - "kind-of": "^6.0.0" + "hasown": "^2.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-date-object": { @@ -4940,17 +5339,16 @@ } }, "node_modules/is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", "dev": true, "dependencies": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/is-extendable": { @@ -4978,7 +5376,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", - "optional": true, + "devOptional": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -4999,9 +5397,9 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "engines": { "node": ">= 0.4" @@ -5037,6 +5435,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "node_modules/is-number/node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -5108,12 +5512,15 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5161,12 +5568,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -5343,33 +5750,14 @@ "node": ">=6" } }, - "node_modules/istanbul-lib-source-maps/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/istanbul-lib-source-maps/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" + "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" @@ -5496,6 +5884,12 @@ "bignumber.js": "^9.0.0" } }, + "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 + }, "node_modules/json-duplicate-key-handle": { "resolved": "opensource/json-duplicate-key-handle", "link": true @@ -5594,9 +5988,9 @@ } }, "node_modules/just-extend": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", - "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", "dev": true }, "node_modules/jwa": { @@ -5619,26 +6013,26 @@ } }, "node_modules/kafka-node": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/kafka-node/-/kafka-node-2.6.1.tgz", - "integrity": "sha512-tpivkSLjiGHRLwx0YN87fMUATOK4NYWESJneHlpikEBNNA5od7fW/ikovS3tWooMqG4Nri55vPFRUNiNvNBWZA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/kafka-node/-/kafka-node-5.0.0.tgz", + "integrity": "sha512-dD2ga5gLcQhsq1yNoQdy1MU4x4z7YnXM5bcG9SdQuiNr5KKuAmXixH1Mggwdah5o7EfholFbcNDPSVA6BIfaug==", "dependencies": { - "async": "^2.5.0", + "async": "^2.6.2", "binary": "~0.3.0", - "bl": "^1.2.0", + "bl": "^2.2.0", "buffer-crc32": "~0.2.5", "buffermaker": "~1.2.0", "debug": "^2.1.3", + "denque": "^1.3.0", "lodash": "^4.17.4", "minimatch": "^3.0.2", "nested-error-stacks": "^2.0.0", - "node-zookeeper-client": "~0.2.2", "optional": "^0.1.3", "retry": "^0.10.1", "uuid": "^3.0.0" }, "engines": { - "node": ">=4.5.0" + "node": ">=8.5.1" }, "optionalDependencies": { "snappy": "^6.0.1" @@ -5657,6 +6051,24 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/kafka-node/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/keyv": { + "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" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -5816,6 +6228,12 @@ "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -5912,24 +6330,101 @@ "lodash.escape": "^3.0.0" } }, - "node_modules/lolex": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-4.2.0.tgz", - "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", + "node_modules/log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "dependencies": { + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/loupe": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", - "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", "dev": true, "dependencies": { - "get-func-name": "^2.0.0" + "get-func-name": "^2.0.1" } }, "node_modules/lru-cache": { @@ -6001,22 +6496,32 @@ } }, "node_modules/memfs": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.0.tgz", - "integrity": "sha512-yK6o8xVJlQerz57kvPROwTMgx5WtGwC2ZxDtOUsnGl49rHjYkfQoPNZPCKH73VdLE1BwBu/+Fx/NL8NYMUw2aA==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.11.1.tgz", + "integrity": "sha512-LZcMTBAgqUUKNXZagcZxvXXfgF1bHX7Y7nQ0QyEiNbRJgE29GhgPd8Yna1VQcLlPiHt/5RFJMWYN9Uv/VPNvjQ==", "dev": true, "dependencies": { - "fs-monkey": "^1.0.3" + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" }, "engines": { "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-source-map": { "version": "1.1.0", @@ -6113,59 +6618,132 @@ } }, "node_modules/mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", - "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "devOptional": true, "dependencies": { - "minimist": "0.0.8" + "minimist": "^1.2.5" }, "bin": { "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp/node_modules/minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", - "devOptional": true - }, "node_modules/mocha": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", - "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", + "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", "dev": true, "dependencies": { + "ansi-colors": "3.2.3", "browser-stdout": "1.3.1", - "commander": "2.15.1", - "debug": "3.1.0", + "chokidar": "3.3.0", + "debug": "3.2.6", "diff": "3.5.0", "escape-string-regexp": "1.0.5", - "glob": "7.1.2", + "find-up": "3.0.0", + "glob": "7.1.3", "growl": "1.10.5", - "he": "1.1.1", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "3.0.0", "minimatch": "3.0.4", - "mkdirp": "0.5.1", - "supports-color": "5.4.0" + "mkdirp": "0.5.5", + "ms": "2.1.1", + "node-environment-flags": "1.0.6", + "object.assign": "4.1.0", + "strip-json-comments": "2.0.1", + "supports-color": "6.0.0", + "which": "1.3.1", + "wide-align": "1.1.3", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "1.6.0" }, "bin": { "_mocha": "bin/_mocha", "mocha": "bin/mocha" }, "engines": { - "node": ">= 4.0.0" + "node": ">= 8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/mocha/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" } }, + "node_modules/mocha/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, "node_modules/mocha/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", "dev": true, "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -6175,6 +6753,18 @@ "node": ">=0.8.0" } }, + "node_modules/mocha/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/mocha/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -6184,6 +6774,41 @@ "node": ">=4" } }, + "node_modules/mocha/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/mocha/node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -6197,37 +6822,173 @@ } }, "node_modules/mocha/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true }, - "node_modules/mocha/node_modules/supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "node_modules/mocha/node_modules/object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", "dev": true, "dependencies": { - "has-flag": "^3.0.0" + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" }, "engines": { - "node": ">=4" + "node": ">= 0.4" } }, - "node_modules/module-not-found-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", - "dev": true - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/multipipe": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz", + "node_modules/mocha/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mocha/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", + "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mocha/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/mocha/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/multipipe": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz", "integrity": "sha512-7ZxrUybYv9NonoXgwoOqtStIu18D1c3eFZj27hqgf5kBrBF8Q+tE8V0MW8dKM5QLkQPh1JhhbKgHLY9kifov4Q==", "dev": true, "dependencies": { @@ -6243,9 +7004,9 @@ } }, "node_modules/nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "optional": true }, "node_modules/nanomatch": { @@ -6295,54 +7056,41 @@ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==" }, "node_modules/nise": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", - "integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", "dev": true, "dependencies": { - "@sinonjs/formatio": "^3.2.1", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "lolex": "^5.0.1", - "path-to-regexp": "^1.7.0" + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" } }, - "node_modules/nise/node_modules/lolex": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz", - "integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==", + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, "dependencies": { - "@sinonjs/commons": "^1.7.0" + "@sinonjs/commons": "^3.0.0" } }, "node_modules/nock": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/nock/-/nock-10.0.0.tgz", - "integrity": "sha512-hE0O9Uhrg7uOpAqnA6ZfnvCS/TZy0HJgMslJ829E7ZuRytcS86/LllupHDD6Tl8fFKQ24kWe1ikX3MCrKkwaaQ==", + "version": "11.9.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.9.1.tgz", + "integrity": "sha512-U5wPctaY4/ar2JJ5Jg4wJxlbBfayxgKbiAeGh+a1kk6Pwnc2ZEuKviLyDSG6t0uXl56q7AALIxoM6FJrBSsVXA==", "dev": true, - "engines": [ - "node >= 4.0" - ], "dependencies": { - "chai": "^4.1.2", - "debug": "^3.1.0", - "deep-equal": "^1.0.0", + "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", - "lodash": "^4.17.5", + "lodash": "^4.17.13", "mkdirp": "^0.5.0", - "propagate": "^1.0.0", - "qs": "^6.5.1", - "semver": "^5.5.0" - } - }, - "node_modules/nock/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 8.0" } }, "node_modules/node-abi": { @@ -6354,10 +7102,20 @@ "semver": "^5.4.1" } }, + "node_modules/node-environment-flags": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", + "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", + "dev": true, + "dependencies": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + } + }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -6387,23 +7145,6 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "optional": true }, - "node_modules/node-zookeeper-client": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/node-zookeeper-client/-/node-zookeeper-client-0.2.3.tgz", - "integrity": "sha512-V4gVHxzQ42iwhkANpPryzfjmqi3Ql3xeO9E/px7W5Yi774WplU3YtqUpnvcL/eJit4UqcfuLOgZLkpf0BPhHmg==", - "dependencies": { - "async": "~0.2.7", - "underscore": "~1.4.4" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/node-zookeeper-client/node_modules/async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - }, "node_modules/noop-logger": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", @@ -6422,10 +7163,20 @@ "validate-npm-package-license": "^3.0.1" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", "optional": true, "dependencies": { "are-we-there-yet": "~1.1.2", @@ -6438,7 +7189,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -6556,26 +7307,6 @@ "node": ">=6" } }, - "node_modules/nyc/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nyc/node_modules/is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", @@ -6660,6 +7391,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -6694,6 +7426,16 @@ "node": ">=6" } }, + "node_modules/nyc/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/nyc/node_modules/wrap-ansi": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", @@ -6775,51 +7517,23 @@ "node": ">=0.10.0" } }, - "node_modules/object-copy/node_modules/is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-copy/node_modules/is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/object-copy/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "node_modules/object-copy/node_modules/is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "dependencies": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-copy/node_modules/is-descriptor/node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/object-copy/node_modules/kind-of": { @@ -6835,23 +7549,10 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, "engines": { "node": ">= 0.4" }, @@ -6881,13 +7582,13 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -6914,53 +7615,106 @@ } }, "node_modules/object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", + "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", "dev": true, "dependencies": { - "isobject": "^3.0.1" + "array.prototype.reduce": "^1.0.6", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "gopd": "^1.0.1", + "safe-array-concat": "^1.1.2" }, "engines": { - "node": ">=0.10.0" - } - }, + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", + "dev": true, + "dependencies": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -6978,10 +7732,6 @@ "wrappy": "1" } }, - "node_modules/opentelemetry-proto": { - "resolved": "git+https://git@github.com/open-telemetry/opentelemetry-proto.git#81a296f9dba23e32d77f46d58c8ea4244a2157a6", - "dev": true - }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -6997,17 +7747,17 @@ "integrity": "sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==" }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -7093,6 +7843,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7214,19 +7969,9 @@ } }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dev": true, - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-to-regexp/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==" }, "node_modules/path-type": { "version": "3.0.0", @@ -7263,6 +8008,24 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -7366,6 +8129,15 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prebuild-install": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.0.tgz", @@ -7440,18 +8212,18 @@ } }, "node_modules/propagate": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", - "integrity": "sha512-T/rqCJJaIPYObiLSmaDsIf4PGA7y+pkgYFHmwoXQyOHiDDSO1YCxcztNiRBmV4EZha4QIbID3vQIHkqKu5k0Xg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true, - "engines": [ - "node >= 0.8.1" - ] + "engines": { + "node": ">= 8" + } }, "node_modules/protobufjs": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz", - "integrity": "sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.2.tgz", + "integrity": "sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==", "hasInstallScript": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", @@ -7471,11 +8243,6 @@ "node": ">=12.0.0" } }, - "node_modules/protobufjs/node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" - }, "node_modules/proxyquire": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", @@ -7518,26 +8285,19 @@ } }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", - "dev": true, - "dependencies": { - "side-channel": "^1.0.4" - }, + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "engines": { "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, "node_modules/querystring": { @@ -7700,6 +8460,18 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/readdirp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", + "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", + "dev": true, + "dependencies": { + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -7726,14 +8498,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -7812,12 +8585,13 @@ "node": ">= 6" } }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "engines": { - "node": ">=0.6" + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" } }, "node_modules/require-directory": { @@ -7835,12 +8609,12 @@ "dev": true }, "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "dependencies": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -7911,6 +8685,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -7922,26 +8697,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7966,13 +8721,13 @@ } }, "node_modules/safe-array-concat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", - "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -8018,15 +8773,18 @@ } }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8064,6 +8822,38 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "devOptional": true }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -8127,14 +8917,18 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8184,39 +8978,31 @@ } }, "node_modules/sinon": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", - "integrity": "sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", + "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==", + "deprecated": "16.1.1", "dev": true, "dependencies": { - "@sinonjs/commons": "^1.4.0", - "@sinonjs/formatio": "^3.2.1", - "@sinonjs/samsam": "^3.3.3", - "diff": "^3.5.0", - "lolex": "^4.2.0", - "nise": "^1.5.2", - "supports-color": "^5.5.0" - } - }, - "node_modules/sinon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^10.3.0", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.4", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/sinon/node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, "engines": { - "node": ">=4" + "node": ">=0.3.1" } }, "node_modules/snapdragon": { @@ -8276,6 +9062,12 @@ "node": ">=0.10.0" } }, + "node_modules/snapdragon-util/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "node_modules/snapdragon-util/node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -8321,66 +9113,17 @@ "node": ">=0.10.0" } }, - "node_modules/snapdragon/node_modules/is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/is-accessor-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/is-data-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/snapdragon/node_modules/is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "dependencies": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/snapdragon/node_modules/is-extendable": { @@ -8392,15 +9135,6 @@ "node": ">=0.10.0" } }, - "node_modules/snapdragon/node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/snapdragon/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -8481,30 +9215,11 @@ "which": "^1.3.0" } }, - "node_modules/spawn-wrap/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/spawn-wrap/node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -8536,9 +9251,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -8552,9 +9267,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", "dev": true }, "node_modules/split-string": { @@ -8602,9 +9317,9 @@ } }, "node_modules/sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -8664,75 +9379,17 @@ "node": ">=0.10.0" } }, - "node_modules/static-extend/node_modules/is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", + "node_modules/static-extend/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "dependencies": { - "kind-of": "^3.0.2" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/is-accessor-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/is-data-descriptor/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/statsd-client": { @@ -8779,7 +9436,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", - "optional": true, + "devOptional": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8793,7 +9450,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -8802,7 +9459,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "optional": true, + "devOptional": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -8811,14 +9468,15 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -8828,28 +9486,31 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8960,6 +9621,16 @@ "node": ">= 0.8.0" } }, + "node_modules/tar-stream/node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "optional": true, + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/tar-stream/node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -8989,30 +9660,11 @@ "node": ">=6.0.0" } }, - "node_modules/temp/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/temp/node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -9036,32 +9688,24 @@ "node": ">=6" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/thriftrw": { "version": "3.11.4", "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.11.4.tgz", @@ -9143,11 +9787,6 @@ "path-to-regexp": "^6.1.0" } }, - "node_modules/tiny-request-router/node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" - }, "node_modules/to-buffer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", @@ -9182,6 +9821,12 @@ "node": ">=0.10.0" } }, + "node_modules/to-object-path/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "node_modules/to-object-path/node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -9247,10 +9892,26 @@ "node": "*" } }, + "node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", @@ -9260,9 +9921,10 @@ } }, "node_modules/tslib": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", - "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -9293,9 +9955,9 @@ } }, "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, "engines": { "node": ">=4" @@ -9314,29 +9976,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9346,16 +10009,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9365,14 +10029,20 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9402,10 +10072,10 @@ "node": ">=0.10.0" } }, - "node_modules/underscore": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", - "integrity": "sha512-ZqGrAgaqqZM7LGRzNjLnw5elevWb5M8LEoDMadxIW3OWbcv72wMMgKdwOKpd5Fqxe8choLD8HN3iSj3TUh/giQ==" + "node_modules/undici-types": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==" }, "node_modules/union-value": { "version": "1.0.1", @@ -9541,12 +10211,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8flags": { @@ -9767,16 +10440,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9786,12 +10459,12 @@ } }, "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "optional": true, + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "devOptional": true, "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "string-width": "^1.0.2 || 2" } }, "node_modules/winston": { @@ -9811,6 +10484,15 @@ "node": ">= 0.10.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -9937,96 +10619,288 @@ "decamelize": "^1.2.0" } }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/yargs-unparser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", + "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", + "dev": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "flat": "^4.1.0", + "lodash": "^4.17.15", + "yargs": "^13.3.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/yargs-unparser/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "opensource/json-duplicate-key-handle": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "backslash": "^0.2.0" - }, - "devDependencies": { - "chai": "^3.5.0", - "gulp": "^3.9.1", - "gulp-mocha": "^2.2.0", - "mocha": "^2.4.5" + "node": ">=6" } }, - "opensource/json-duplicate-key-handle/node_modules/chai": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", - "integrity": "sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==", + "node_modules/yargs-unparser/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "dependencies": { - "assertion-error": "^1.0.1", - "deep-eql": "^0.1.3", - "type-detect": "^1.0.0" + "color-convert": "^1.9.0" }, "engines": { - "node": ">= 0.4.0" + "node": ">=4" } }, - "opensource/json-duplicate-key-handle/node_modules/commander": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", - "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", + "node_modules/yargs-unparser/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", "dev": true, - "engines": { - "node": ">= 0.6.x" + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" } }, - "opensource/json-duplicate-key-handle/node_modules/debug": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", - "integrity": "sha512-X0rGvJcskG1c3TgSCPqHJ0XJgwlcvOC7elJ5Y0hYuKBZoVqWpAMfLOeIh2UI/DCQ5ruodIjvsugZtjUYUw2pUw==", + "node_modules/yargs-unparser/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "dependencies": { - "ms": "0.7.1" + "color-name": "1.1.3" } }, - "opensource/json-duplicate-key-handle/node_modules/deep-eql": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "integrity": "sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==", + "node_modules/yargs-unparser/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/yargs-unparser/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "node_modules/yargs-unparser/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/yargs-unparser/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/yargs-unparser/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-unparser/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/yargs-unparser/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "opensource/json-duplicate-key-handle": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "backslash": "^0.2.0" + }, + "devDependencies": { + "chai": "^3.5.0", + "gulp": "^3.9.1", + "gulp-mocha": "^2.2.0", + "mocha": "^2.4.5" + } + }, + "opensource/json-duplicate-key-handle/node_modules/chai": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "integrity": "sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==", + "dev": true, + "dependencies": { + "assertion-error": "^1.0.1", + "deep-eql": "^0.1.3", + "type-detect": "^1.0.0" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "opensource/json-duplicate-key-handle/node_modules/debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha512-X0rGvJcskG1c3TgSCPqHJ0XJgwlcvOC7elJ5Y0hYuKBZoVqWpAMfLOeIh2UI/DCQ5ruodIjvsugZtjUYUw2pUw==", + "dev": true, + "dependencies": { + "ms": "0.7.1" + } + }, + "opensource/json-duplicate-key-handle/node_modules/deep-eql": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==", "dev": true, "dependencies": { "type-detect": "0.1.1" @@ -10066,6 +10940,7 @@ "version": "3.2.11", "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", "integrity": "sha512-hVb0zwEZwC1FXSKRPFTeOtN7AArJcJlI6ULGLtrstaswKNlrTJqAA+1lYlSUop4vjA423xlBzqfVS3iWGlqJ+g==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "inherits": "2", @@ -10101,6 +10976,25 @@ "node": "*" } }, + "opensource/json-duplicate-key-handle/node_modules/minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", + "dev": true + }, + "opensource/json-duplicate-key-handle/node_modules/mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "dev": true, + "dependencies": { + "minimist": "0.0.8" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "opensource/json-duplicate-key-handle/node_modules/mocha": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", @@ -10155,88 +11049,50 @@ } }, "dependencies": { - "@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true - }, "@babel/code-frame": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", - "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, "requires": { - "@babel/highlight": "^7.22.5" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" } }, "@babel/generator": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.9.tgz", - "integrity": "sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dev": true, "requires": { - "@babel/types": "^7.22.5", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.25.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" } }, - "@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", - "dev": true - }, - "@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", - "dev": true, - "requires": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, "@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true }, "@babel/highlight": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz", - "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.22.5", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" + "@babel/helper-validator-identifier": "^7.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "dependencies": { "ansi-styles": { @@ -10298,37 +11154,37 @@ } }, "@babel/parser": { - "version": "7.22.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.7.tgz", - "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==", - "dev": true + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", + "dev": true, + "requires": { + "@babel/types": "^7.25.2" + } }, "@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "requires": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" } }, "@babel/traverse": { - "version": "7.22.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.8.tgz", - "integrity": "sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.22.5", - "@babel/generator": "^7.22.7", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.7", - "@babel/types": "^7.22.5", - "debug": "^4.1.0", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", + "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", + "debug": "^4.3.1", "globals": "^11.1.0" }, "dependencies": { @@ -10341,35 +11197,26 @@ } }, "@babel/types": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz", - "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" } }, - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, "@eslint-community/regexpp": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.0.tgz", - "integrity": "sha512-uiPeRISaglZnaZk8vwrjQZ1CxogZeY/4IYft6gBOTqu1WhVXWmCmZMWxUv2Q/pxSvPdp1JPaO62kLOcOkMqWrw==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true }, "@eslint/eslintrc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz", - "integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "requires": { "ajv": "^6.12.4", @@ -10384,9 +11231,9 @@ } }, "@eslint/js": { - "version": "8.44.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz", - "integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true }, "@f5devcentral/atg-shared-utilities": { @@ -10399,9 +11246,9 @@ } }, "@f5devcentral/atg-storage": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@f5devcentral/atg-storage/-/atg-storage-1.3.9.tgz", - "integrity": "sha512-50+q1gHlGSoCmsey+lrMbcW3NnWoiUv3aYPqAgVHBWAf3OXqOG8ppUJ8hC2RsOpevOkxuHDs1q5VQDBeOwY0Gg==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@f5devcentral/atg-storage/-/atg-storage-1.3.10.tgz", + "integrity": "sha512-vgk2B/INrOlxBGyfrOqn2hDPPfAofeeZpMMBAkaXU1tA18VU7erZ202OJ5Yw9ZvUYEwoda2crPIR4vwL+AhzEw==", "requires": { "@f5devcentral/atg-shared-utilities": "^0.6.0" } @@ -10423,37 +11270,43 @@ "requires": { "@f5devcentral/atg-storage": "^1.3.9", "uuid": "^3.4.0" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "@grpc/grpc-js": { - "version": "1.8.18", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.8.18.tgz", - "integrity": "sha512-2uWPtxhsXmVgd8WzDhfamSjHpZDXfMjMDciY6VRTq4Sn7rFzazyf0LLDa0oav+61UHIoEZb4KKaAV6S7NuJFbQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.11.1.tgz", + "integrity": "sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw==", "requires": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" } }, "@grpc/proto-loader": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.8.tgz", - "integrity": "sha512-GU12e2c8dmdXb7XUlOgYWZ2o2i+z9/VeACkxTA/zzAe2IjclC5PnVL0lpgjhrqfpDYHzM8B1TF6pqWegMYAzlA==", + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", "requires": { - "@types/long": "^4.0.1", "lodash.camelcase": "^4.3.0", - "long": "^4.0.0", - "protobufjs": "^7.2.4", + "long": "^5.0.0", + "protobufjs": "^7.2.5", "yargs": "^17.7.2" } }, "@humanwhocodes/config-array": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", - "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" } }, @@ -10464,58 +11317,81 @@ "dev": true }, "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "requires": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" } }, "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true }, "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - }, - "dependencies": { - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - } + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==" + }, + "@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/json-pack": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.0.tgz", + "integrity": "sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==", + "dev": true, + "requires": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" } }, + "@jsonjoy.com/util": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", + "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "dev": true, + "requires": {} + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10548,161 +11424,217 @@ "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==" }, "@opentelemetry/api-logs": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.41.0.tgz", - "integrity": "sha512-kopW4ZEKX2mgaPi9jh3lTP+2ixbe0z+tAEOn3v0ZM6jzQl7z+2C1ZZjU1cVYbX+RDGqu7n6BMyv5wmWuqiuKYQ==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.41.2.tgz", + "integrity": "sha512-JEV2RAqijAFdWeT6HddYymfnkiRu2ASxoTBr4WsnGJhOjWZkEy6vp+Sx9ozr1NaIODOa2HUyckExIqQjn6qywQ==", "requires": { - "@opentelemetry/api": "^1.0.0", - "tslib": "^2.3.1" + "@opentelemetry/api": "^1.0.0" } }, "@opentelemetry/core": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.0.tgz", - "integrity": "sha512-GGTS6BytfaN8OgbCUOnxg/a9WVsVUj0484zXHZuBzvIXx7V4Tmkb0IHnnhS7Q0cBLNLgjNuvrCpQaP8fIvO4bg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.2.tgz", + "integrity": "sha512-+gBv15ta96WqkHZaPpcDHiaz0utiiHZVfm2YOYSqFGrUaJpPkMoSuLBB58YFQGi6Rsb9EHos84X6X5+9JspmLw==", "requires": { - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/semantic-conventions": "1.15.2" } }, "@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.41.0.tgz", - "integrity": "sha512-IVLf07OTFmPs6SwViYNBGPTnOGN2gDLhQiw/O60m7CBvBOfEfcg83w/bVF4Va3m6H5cReVbQsKEx+AaCVl6smg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.41.2.tgz", + "integrity": "sha512-gQuCcd5QSMkfi1XIriWAoak/vaRvFzpvtzh2hjziIvbnA3VtoGD3bDb2dzEzOA1iSWO0/tHwnBsSmmUZsETyOA==", "requires": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.41.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/exporter-metrics-otlp-http": "0.41.2", + "@opentelemetry/otlp-grpc-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" + }, + "dependencies": { + "@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "requires": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + } + } } }, "@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.41.0.tgz", - "integrity": "sha512-YttGW1XEHB9GocXtEY+n0qAT2Ewi/P4l7882kYK4kEl78EAnVvvWvFX1El+TvHA3D2LHDxx9ASu1i+icCqj/Fw==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.41.2.tgz", + "integrity": "sha512-+YeIcL4nuldWE89K8NBLImpXCvih04u1MBnn8EzvoywG2TKR5JC3CZEPepODIxlsfGSgP8W5khCEP1NHZzftYw==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" + }, + "dependencies": { + "@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "requires": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + } + } } }, "@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.41.0.tgz", - "integrity": "sha512-6cQlyV/cQk/kzyI/u/eoAOjtQO+SkWUJbnyI1nWGYADwtbJtJ4sl6ks7t7cdppTr7/66fMgXVKIIjjPowoEcGw==", - "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.41.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "@opentelemetry/otlp-proto-exporter-base": "0.41.0", - "@opentelemetry/otlp-transformer": "0.41.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "tslib": "^2.3.1" + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.41.2.tgz", + "integrity": "sha512-OLNs6wF84uhxn8TJ8Bv1q2ltdJqjKA9oUEtICcUDDzXIiztPxZ9ur/4xdMk9T3ZJeFMfrhj8eYDkpETBy+fjCg==", + "requires": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/exporter-metrics-otlp-http": "0.41.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "@opentelemetry/otlp-proto-exporter-base": "0.41.2", + "@opentelemetry/otlp-transformer": "0.41.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-metrics": "1.15.2" + }, + "dependencies": { + "@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "requires": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + } + } } }, "@opentelemetry/otlp-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.41.0.tgz", - "integrity": "sha512-fSHtZznIU6kvCLFQC77nOhHj059G1sc/wNl96YiPdro4A8t8ue//ET0yAtpRCQ9lynn4RNrpsw5iEFJszEbmLg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.41.2.tgz", + "integrity": "sha512-pfwa6d+Dax3itZcGWiA0AoXeVaCuZbbqUTsCtOysd2re8C2PWXNxDONUfBWsn+KgxAdi+ljwTjJGiaVLDaIEvQ==", "requires": { - "@opentelemetry/core": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2" } }, "@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.41.0.tgz", - "integrity": "sha512-TdbZ46i2kKeGKE9SCZFiSt1iTLHS+DniEaWbVsIhEPOLZXl8TGzzi1FjR/Q3gG/vlblYZ/MdgXHgRIGVG5qIDw==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.41.2.tgz", + "integrity": "sha512-OErK8dYjXG01XIMIpmOV2SzL9ctkZ0Nyhf2UumICOAKtgLvR5dG1JMlsNVp8Jn0RzpsKc6Urv7JpP69wzRXN+A==", "requires": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "protobufjs": "^7.2.3", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "protobufjs": "^7.2.3" } }, "@opentelemetry/otlp-proto-exporter-base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-proto-exporter-base/-/otlp-proto-exporter-base-0.41.0.tgz", - "integrity": "sha512-VY/7y8ne72PIzPxFN3uzHfrmxo9rCDWP08/fY3iodjizCxmCCRFM4Sb7VX0ZSrjakL1mLXFd0FSwe71AsAtM9A==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-proto-exporter-base/-/otlp-proto-exporter-base-0.41.2.tgz", + "integrity": "sha512-BxmEMiP6tHiFroe5/dTt9BsxCci7BTLtF7A6d4DKHLiLweWWZxQ9l7hON7qt/IhpKrQcAFD1OzZ1Gq2ZkNzhCw==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/otlp-exporter-base": "0.41.0", - "protobufjs": "^7.2.3", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/otlp-exporter-base": "0.41.2", + "protobufjs": "^7.2.3" } }, "@opentelemetry/otlp-transformer": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.41.0.tgz", - "integrity": "sha512-a5GqVSdVIhAoYcQrdWQAeMbrkz0iDwKC6BUsuqPuykh+T4QZzrF6cwneOXKbQI5Dl7ms6ha9dYHf4Ka0kc66ZQ==", - "requires": { - "@opentelemetry/api-logs": "0.41.0", - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/sdk-logs": "0.41.0", - "@opentelemetry/sdk-metrics": "1.15.0", - "@opentelemetry/sdk-trace-base": "1.15.0", - "tslib": "^2.3.1" + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.41.2.tgz", + "integrity": "sha512-jJbPwB0tNu2v+Xi0c/v/R3YBLJKLonw1p+v3RVjT2VfzeUyzSp/tBeVdY7RZtL6dzZpA9XSmp8UEfWIFQo33yA==", + "requires": { + "@opentelemetry/api-logs": "0.41.2", + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/sdk-logs": "0.41.2", + "@opentelemetry/sdk-metrics": "1.15.2", + "@opentelemetry/sdk-trace-base": "1.15.2" + }, + "dependencies": { + "@opentelemetry/sdk-metrics": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.2.tgz", + "integrity": "sha512-9aIlcX8GnhcsAHW/Wl8bzk4ZnWTpNlLtud+fxUfBtFATu6OZ6TrGrF4JkT9EVrnoxwtPIDtjHdEsSjOqisY/iA==", + "requires": { + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "lodash.merge": "^4.6.2" + } + } } }, "@opentelemetry/resources": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.15.0.tgz", - "integrity": "sha512-Sb8A6ZXHXDlgHv32UNRE3y8McWE3vkb5dsSttYArYa5ZpwjiF5ge0vnnKUUnG7bY0AgF9VBIOORZE8gsrnD2WA==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.15.2.tgz", + "integrity": "sha512-xmMRLenT9CXmm5HMbzpZ1hWhaUowQf8UB4jMjFlAxx1QzQcsD3KFNAVX/CAWzFPtllTyTplrA4JrQ7sCH3qmYw==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/semantic-conventions": "1.15.2" } }, "@opentelemetry/sdk-logs": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.41.0.tgz", - "integrity": "sha512-+Qs8uHcd/tYKS1n6lfSPiQXMOuyPN0c3xKeyWjD5mExRvmA1H6SIYfZmB6KeQNXWODK4z4JtWo5g5Efe0gJ1Vg==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.41.2.tgz", + "integrity": "sha512-smqKIw0tTW15waj7BAPHFomii5c3aHnSE4LQYTszGoK5P9nZs8tEAIpu15UBxi3aG31ZfsLmm4EUQkjckdlFrw==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2" } }, "@opentelemetry/sdk-metrics": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.15.0.tgz", - "integrity": "sha512-fFUnAcPvlXO39nlIduGuaeCuiZyFtSLCn9gW/0djFRO5DFst4m4gcT6+llXvNWuUvtGB49s56NP10B9IZRN0Rw==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", + "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "lodash.merge": "^4.6.2", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.25.1", + "@opentelemetry/resources": "1.25.1", + "lodash.merge": "^4.6.2" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", + "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", + "requires": { + "@opentelemetry/semantic-conventions": "1.25.1" + } + }, + "@opentelemetry/resources": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", + "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", + "requires": { + "@opentelemetry/core": "1.25.1", + "@opentelemetry/semantic-conventions": "1.25.1" + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", + "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" + } } }, "@opentelemetry/sdk-trace-base": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.15.0.tgz", - "integrity": "sha512-udt1c9VHipbZwvCPIQR1VLg25Z4AMR/g0X8KmcInbFruGWQ/lptVPkz3yvWAsGSta5yHNQ3uoPwcyCygGnQ6Lg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.15.2.tgz", + "integrity": "sha512-BEaxGZbWtvnSPchV98qqqqa96AOcb41pjgvhfzDij10tkBhIu9m0Jd6tZ1tJB5ZHfHbTffqYVYE0AOGobec/EQ==", "requires": { - "@opentelemetry/core": "1.15.0", - "@opentelemetry/resources": "1.15.0", - "@opentelemetry/semantic-conventions": "1.15.0", - "tslib": "^2.3.1" + "@opentelemetry/core": "1.15.2", + "@opentelemetry/resources": "1.15.2", + "@opentelemetry/semantic-conventions": "1.15.2" } }, "@opentelemetry/semantic-conventions": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.0.tgz", - "integrity": "sha512-f3wwFrFyCpGrFBrFs7lCUJSCSCGyeKG52c+EKeobs3Dd29M75yO6GYkt6PkYPfDawxSlV5p+4yJPPk8tPObzTQ==", - "requires": { - "tslib": "^2.3.1" - } + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.2.tgz", + "integrity": "sha512-CjbOKwk2s+3xPIMcd5UNYQzsf+v94RczbdNix9/kQh38WiQkM90sUOi3if8eyHFgiBjBjhwXrA7W3ydiSQP9mw==" }, "@protobufjs/aspromise": { "version": "1.1.2", @@ -10759,33 +11691,57 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "requires": { "type-detect": "4.0.8" + }, + "dependencies": { + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + } } }, - "@sinonjs/formatio": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz", - "integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==", + "@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "requires": { - "@sinonjs/commons": "^1", - "@sinonjs/samsam": "^3.1.0" + "@sinonjs/commons": "^3.0.0" } }, "@sinonjs/samsam": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz", - "integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", "dev": true, "requires": { - "@sinonjs/commons": "^1.3.0", - "array-from": "^2.1.1", - "lodash": "^4.17.15" + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + } } }, "@sinonjs/text-encoding": { @@ -10800,15 +11756,19 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, "@types/node": { - "version": "20.4.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.4.tgz", - "integrity": "sha512-CukZhumInROvLq3+b5gLev+vgpsIqC2D0deQr/yS1WnxvmYLlJXZpaQrQiseMY+6xusl79E04UjWoqyr+t1/Ew==" + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", + "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==", + "requires": { + "undici-types": "~6.13.0" + } + }, + "@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true }, "abort-controller": { "version": "3.0.0", @@ -10819,9 +11779,9 @@ } }, "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true }, "acorn-jsx": { @@ -10862,6 +11822,12 @@ "integrity": "sha512-bF6xLaZBLpOQzgYUtYEhJx090nPSZk1BQ/q2oyBK9aMMcJHzx9uXGCjI2Y+LebsN4Jwoykr0V9whbPiogdyHoQ==", "optional": true }, + "ansi-colors": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", + "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", + "dev": true + }, "ansi-gray": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", @@ -10890,6 +11856,16 @@ "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", "dev": true }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, "append-transform": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", @@ -10957,13 +11933,13 @@ "dev": true }, "array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" } }, "array-differ": { @@ -10978,22 +11954,17 @@ "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", "dev": true }, - "array-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", - "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==", - "dev": true - }, "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" } }, @@ -11015,41 +11986,72 @@ "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", "dev": true }, + "array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + } + }, "array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" } }, "array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" } }, + "array.prototype.reduce": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", + "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "is-string": "^1.0.7" + } + }, "arraybuffer.prototype.slice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.1.tgz", - "integrity": "sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "requires": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" } }, @@ -11120,10 +12122,13 @@ "dev": true }, "available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "requires": { + "possible-typed-array-names": "^1.0.0" + } }, "aws-sdk": { "version": "2.1018.0", @@ -11159,9 +12164,9 @@ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" }, "aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.1.tgz", + "integrity": "sha512-u5w79Rd7SU4JaIlA/zFqG+gOiuq25q5VLyZ8E+ijJeILuTxVzZgp2CaGw/UTw6pXYN9XMO9yiqj/nEHmhTG5CA==" }, "backslash": { "version": "0.2.0", @@ -11219,9 +12224,9 @@ "dev": true }, "bignumber.js": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", - "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==" + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" }, "binary": { "version": "0.3.0", @@ -11232,6 +12237,12 @@ "chainsaw": "~0.1.0" } }, + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -11247,9 +12258,9 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, "bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", "requires": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -11368,9 +12379,9 @@ "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==" }, "bufrw": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.3.0.tgz", - "integrity": "sha512-jzQnSbdJqhIltU9O5KUiTtljP9ccw2u5ix59McQy4pV2xGhVLhRZIndY8GIrgh5HjXa6+QJ9AQhOd2QWQizJFQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/bufrw/-/bufrw-1.4.0.tgz", + "integrity": "sha512-sWm8iPbqvL9+5SiYxXH73UOkyEbGQg7kyHQmReF89WJHQJw2eV4P/yZ0E+b71cczJ4pPobVhXxgQcmfSTgGHxQ==", "optional": true, "requires": { "ansi-color": "^0.2.1", @@ -11409,13 +12420,16 @@ } }, "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" } }, "callsites": { @@ -11436,24 +12450,24 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, "chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "requires": { "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "type-detect": "^4.1.0" } }, "chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.2.tgz", + "integrity": "sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==", "dev": true, "requires": { "check-error": "^1.0.2" @@ -11478,10 +12492,73 @@ } }, "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "requires": { + "get-func-name": "^2.0.2" + } + }, + "chokidar": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", + "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.1", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.2.0" + }, + "dependencies": { + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } }, "chownr": { "version": "1.1.4", @@ -11510,62 +12587,15 @@ "is-descriptor": "^0.1.0" } }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } - }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true } } }, @@ -11622,7 +12652,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", - "optional": true + "devOptional": true }, "collection-visit": { "version": "1.0.0", @@ -11668,9 +12698,9 @@ } }, "commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", + "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", "dev": true }, "commondir": { @@ -11680,9 +12710,9 @@ "dev": true }, "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true }, "concat-map": { @@ -11766,6 +12796,39 @@ "assert-plus": "^1.0.0" } }, + "data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + } + }, + "data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + } + }, + "data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "requires": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + } + }, "dateformat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", @@ -11773,9 +12836,9 @@ "dev": true }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "requires": { "ms": "2.1.2" } @@ -11808,28 +12871,14 @@ "dev": true }, "deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, "requires": { "type-detect": "^4.0.0" } }, - "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "dev": true, - "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" - } - }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -11860,12 +12909,24 @@ "clone": "^1.0.2" } }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, "define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } @@ -11891,6 +12952,11 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "optional": true }, + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + }, "deprecated": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz", @@ -12042,70 +13108,107 @@ } }, "es-abstract": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz", - "integrity": "sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==", - "dev": true, - "requires": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.1", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.1", - "get-symbol-description": "^1.0.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "safe-array-concat": "^1.0.0", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.10" + "which-typed-array": "^1.1.15" } }, - "es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", "dev": true, "requires": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" + "get-intrinsic": "^1.2.4" } }, - "es-shim-unscopables": { + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-object-atoms": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "dev": true, "requires": { - "has": "^1.0.3" + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + } + }, + "es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" } }, "es-to-primitive": { @@ -12126,9 +13229,9 @@ "dev": true }, "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==" }, "escape-string-regexp": { "version": "4.0.0", @@ -12137,27 +13240,28 @@ "dev": true }, "eslint": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz", - "integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.1.0", - "@eslint/js": "8.44.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.6.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -12179,6 +13283,17 @@ "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" + }, + "dependencies": { + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + } } }, "eslint-config-airbnb-base": { @@ -12202,14 +13317,14 @@ } }, "eslint-import-resolver-node": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", - "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "requires": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" }, "dependencies": { "debug": { @@ -12224,9 +13339,9 @@ } }, "eslint-module-utils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", + "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", "dev": true, "requires": { "debug": "^3.2.7" @@ -12244,26 +13359,28 @@ } }, "eslint-plugin-import": { - "version": "2.27.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", - "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "requires": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", - "eslint-module-utils": "^2.7.4", - "has": "^1.0.3", - "is-core-module": "^2.11.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.6", - "resolve": "^1.22.1", - "semver": "^6.3.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" }, "dependencies": { "debug": { @@ -12293,9 +13410,9 @@ } }, "eslint-scope": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz", - "integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "requires": { "esrecurse": "^4.3.0", @@ -12303,9 +13420,9 @@ } }, "eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true }, "espree": { @@ -12326,9 +13443,9 @@ "dev": true }, "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -12412,55 +13529,14 @@ "is-extendable": "^0.1.0" } }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } }, "is-extendable": { @@ -12469,12 +13545,6 @@ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -12600,9 +13670,9 @@ "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" }, "fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "requires": { "reusify": "^1.0.4" @@ -12737,20 +13807,30 @@ "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", "dev": true }, + "flat": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", + "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", + "dev": true, + "requires": { + "is-buffer": "~2.0.3" + } + }, "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "requires": { - "flatted": "^3.1.0", + "flatted": "^3.2.9", + "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "for-each": { @@ -12854,34 +13934,35 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "optional": true }, - "fs-monkey": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.4.tgz", - "integrity": "sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==", - "dev": true - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "dev": true, + "optional": true + }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true }, "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" } }, "functions-have-names": { @@ -12965,31 +14046,33 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true }, "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" } }, "get-value": { @@ -13013,9 +14096,9 @@ "optional": true }, "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "requires": { "fs.realpath": "^1.0.0", @@ -13160,21 +14243,22 @@ } }, "globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "requires": { "type-fest": "^0.20.2" } }, "globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "requires": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" } }, "globule": { @@ -13394,12 +14478,6 @@ "through": "^2.3.4" }, "dependencies": { - "commander": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", - "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", - "dev": true - }, "debug": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", @@ -13453,6 +14531,21 @@ "sigmund": "~1.0.0" } }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, "mocha": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", @@ -13582,15 +14675,6 @@ "har-schema": "^2.0.0" } }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -13630,18 +14714,18 @@ } }, "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "requires": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" } }, "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true }, "has-symbols": { @@ -13651,12 +14735,12 @@ "dev": true }, "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "requires": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" } }, "has-unicode": { @@ -13686,6 +14770,12 @@ "kind-of": "^4.0.0" }, "dependencies": { + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "kind-of": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", @@ -13714,12 +14804,26 @@ } } }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "hexer": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/hexer/-/hexer-1.5.0.tgz", @@ -13772,6 +14876,12 @@ "debug": "4" } }, + "hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true + }, "icrdk": { "version": "git+https://git@github.com/f5devcentral/f5-icontrollx-dev-kit.git#b439e63cc8936059f93392e88c3a653a4f823cb3", "dev": true, @@ -13783,9 +14893,9 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true }, "import-fresh": { @@ -13834,13 +14944,13 @@ "devOptional": true }, "internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, "requires": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "hasown": "^2.0.0", "side-channel": "^1.0.4" } }, @@ -13867,33 +14977,22 @@ } }, "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "hasown": "^2.0.0" } }, "is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "requires": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" } }, "is-arrayish": { @@ -13911,6 +15010,15 @@ "has-bigints": "^1.0.1" } }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, "is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -13922,9 +15030,9 @@ } }, "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", "dev": true }, "is-callable": { @@ -13934,21 +15042,30 @@ "dev": true }, "is-core-module": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", - "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", + "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", "dev": true, "requires": { - "has": "^1.0.3" + "hasown": "^2.0.2" } }, "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", "dev": true, "requires": { - "kind-of": "^6.0.0" + "hasown": "^2.0.0" + } + }, + "is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "requires": { + "is-typed-array": "^1.1.13" } }, "is-date-object": { @@ -13961,14 +15078,13 @@ } }, "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", "dev": true, "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } }, "is-extendable": { @@ -13990,7 +15106,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", - "optional": true, + "devOptional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -14005,9 +15121,9 @@ } }, "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true }, "is-number": { @@ -14019,6 +15135,12 @@ "kind-of": "^3.0.2" }, "dependencies": { + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -14080,12 +15202,12 @@ } }, "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, "requires": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" } }, "is-stream": { @@ -14112,12 +15234,12 @@ } }, "is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "requires": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" } }, "is-typedarray": { @@ -14256,20 +15378,6 @@ "source-map": "^0.6.1" }, "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -14374,6 +15482,12 @@ "bignumber.js": "^9.0.0" } }, + "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 + }, "json-duplicate-key-handle": { "version": "file:opensource/json-duplicate-key-handle", "requires": { @@ -14395,12 +15509,6 @@ "type-detect": "^1.0.0" } }, - "commander": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz", - "integrity": "sha512-CD452fnk0jQyk3NfnK+KkR/hUPoHt5pVaKHogtyyv3N0U4QfAal9W0/rXLOg/vVZgQKa7jdtXypKs1YAip11uQ==", - "dev": true - }, "debug": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", @@ -14471,6 +15579,21 @@ "sigmund": "~1.0.0" } }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, "mocha": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.5.3.tgz", @@ -14595,9 +15718,9 @@ } }, "just-extend": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", - "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", "dev": true }, "jwa": { @@ -14620,20 +15743,20 @@ } }, "kafka-node": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/kafka-node/-/kafka-node-2.6.1.tgz", - "integrity": "sha512-tpivkSLjiGHRLwx0YN87fMUATOK4NYWESJneHlpikEBNNA5od7fW/ikovS3tWooMqG4Nri55vPFRUNiNvNBWZA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/kafka-node/-/kafka-node-5.0.0.tgz", + "integrity": "sha512-dD2ga5gLcQhsq1yNoQdy1MU4x4z7YnXM5bcG9SdQuiNr5KKuAmXixH1Mggwdah5o7EfholFbcNDPSVA6BIfaug==", "requires": { - "async": "^2.5.0", + "async": "^2.6.2", "binary": "~0.3.0", - "bl": "^1.2.0", + "bl": "^2.2.0", "buffer-crc32": "~0.2.5", "buffermaker": "~1.2.0", "debug": "^2.1.3", + "denque": "^1.3.0", "lodash": "^4.17.4", "minimatch": "^3.0.2", "nested-error-stacks": "^2.0.0", - "node-zookeeper-client": "~0.2.2", "optional": "^0.1.3", "retry": "^0.10.1", "snappy": "^6.0.1", @@ -14652,9 +15775,23 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -14795,6 +15932,12 @@ "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -14891,24 +16034,85 @@ "lodash.escape": "^3.0.0" } }, - "lolex": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-4.2.0.tgz", - "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", - "dev": true + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } }, "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "loupe": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", - "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", "dev": true, "requires": { - "get-func-name": "^2.0.0" + "get-func-name": "^2.0.1" } }, "lru-cache": { @@ -14962,18 +16166,21 @@ } }, "memfs": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.0.tgz", - "integrity": "sha512-yK6o8xVJlQerz57kvPROwTMgx5WtGwC2ZxDtOUsnGl49rHjYkfQoPNZPCKH73VdLE1BwBu/+Fx/NL8NYMUw2aA==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.11.1.tgz", + "integrity": "sha512-LZcMTBAgqUUKNXZagcZxvXXfgF1bHX7Y7nQ0QyEiNbRJgE29GhgPd8Yna1VQcLlPiHt/5RFJMWYN9Uv/VPNvjQ==", "dev": true, "requires": { - "fs-monkey": "^1.0.3" + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "dev": true }, "merge-source-map": { @@ -15050,62 +16257,158 @@ } }, "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "devOptional": true, "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", - "devOptional": true - } + "minimist": "^1.2.5" } }, "mocha": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", - "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", + "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", "dev": true, "requires": { + "ansi-colors": "3.2.3", "browser-stdout": "1.3.1", - "commander": "2.15.1", - "debug": "3.1.0", + "chokidar": "3.3.0", + "debug": "3.2.6", "diff": "3.5.0", "escape-string-regexp": "1.0.5", - "glob": "7.1.2", + "find-up": "3.0.0", + "glob": "7.1.3", "growl": "1.10.5", - "he": "1.1.1", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "3.0.0", "minimatch": "3.0.4", - "mkdirp": "0.5.1", - "supports-color": "5.4.0" + "mkdirp": "0.5.5", + "ms": "2.1.1", + "node-environment-flags": "1.0.6", + "object.assign": "4.1.0", + "strip-json-comments": "2.0.1", + "supports-color": "6.0.0", + "which": "1.3.1", + "wide-align": "1.1.3", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "1.6.0" }, "dependencies": { + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", "dev": true, "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -15115,19 +16418,125 @@ "brace-expansion": "^1.1.7" } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + }, + "supports-color": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", + "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "dev": true }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", "dev": true, "requires": { - "has-flag": "^3.0.0" + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" } } } @@ -15158,9 +16567,9 @@ "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" }, "nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "optional": true }, "nanomatch": { @@ -15206,55 +16615,40 @@ "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==" }, "nise": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", - "integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", "dev": true, "requires": { - "@sinonjs/formatio": "^3.2.1", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "lolex": "^5.0.1", - "path-to-regexp": "^1.7.0" + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" }, "dependencies": { - "lolex": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz", - "integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==", + "@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, "requires": { - "@sinonjs/commons": "^1.7.0" + "@sinonjs/commons": "^3.0.0" } } } }, "nock": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/nock/-/nock-10.0.0.tgz", - "integrity": "sha512-hE0O9Uhrg7uOpAqnA6ZfnvCS/TZy0HJgMslJ829E7ZuRytcS86/LllupHDD6Tl8fFKQ24kWe1ikX3MCrKkwaaQ==", + "version": "11.9.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.9.1.tgz", + "integrity": "sha512-U5wPctaY4/ar2JJ5Jg4wJxlbBfayxgKbiAeGh+a1kk6Pwnc2ZEuKviLyDSG6t0uXl56q7AALIxoM6FJrBSsVXA==", "dev": true, "requires": { - "chai": "^4.1.2", - "debug": "^3.1.0", - "deep-equal": "^1.0.0", + "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", - "lodash": "^4.17.5", + "lodash": "^4.17.13", "mkdirp": "^0.5.0", - "propagate": "^1.0.0", - "qs": "^6.5.1", - "semver": "^5.5.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } + "propagate": "^2.0.0" } }, "node-abi": { @@ -15266,10 +16660,20 @@ "semver": "^5.4.1" } }, + "node-environment-flags": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", + "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", + "dev": true, + "requires": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + } + }, "node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "requires": { "whatwg-url": "^5.0.0" } @@ -15285,22 +16689,6 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "optional": true }, - "node-zookeeper-client": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/node-zookeeper-client/-/node-zookeeper-client-0.2.3.tgz", - "integrity": "sha512-V4gVHxzQ42iwhkANpPryzfjmqi3Ql3xeO9E/px7W5Yi774WplU3YtqUpnvcL/eJit4UqcfuLOgZLkpf0BPhHmg==", - "requires": { - "async": "~0.2.7", - "underscore": "~1.4.4" - }, - "dependencies": { - "async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - } - } - }, "noop-logger": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", @@ -15319,6 +16707,12 @@ "validate-npm-package-license": "^3.0.1" } }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, "npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", @@ -15335,7 +16729,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", - "optional": true + "devOptional": true }, "nyc": { "version": "14.1.1", @@ -15435,20 +16829,6 @@ "locate-path": "^3.0.0" } }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", @@ -15534,6 +16914,12 @@ "ansi-regex": "^4.1.0" } }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + }, "wrap-ansi": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", @@ -15602,41 +16988,20 @@ "is-descriptor": "^0.1.0" } }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - } + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } }, "kind-of": { @@ -15651,21 +17016,11 @@ } }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "dev": true }, - "object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -15682,13 +17037,13 @@ } }, "object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" } @@ -15706,14 +17061,52 @@ } }, "object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + } + }, + "object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", + "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", + "dev": true, + "requires": { + "array.prototype.reduce": "^1.0.6", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "gopd": "^1.0.1", + "safe-array-concat": "^1.1.2" + } + }, + "object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" } }, "object.map": { @@ -15736,14 +17129,14 @@ } }, "object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" } }, "once": { @@ -15755,11 +17148,6 @@ "wrappy": "1" } }, - "opentelemetry-proto": { - "version": "git+https://git@github.com/open-telemetry/opentelemetry-proto.git#81a296f9dba23e32d77f46d58c8ea4244a2157a6", - "dev": true, - "from": "opentelemetry-proto@git+https://git@github.com/open-telemetry/opentelemetry-proto.git#main" - }, "opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -15772,17 +17160,17 @@ "integrity": "sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==" }, "optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "requires": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" } }, "orchestrator": { @@ -15844,6 +17232,11 @@ "release-zalgo": "^1.0.0" } }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -15932,21 +17325,9 @@ "dev": true }, "path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dev": true, - "requires": { - "isarray": "0.0.1" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - } - } + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==" }, "path-type": { "version": "3.0.0", @@ -15976,6 +17357,18 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, + "picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -16051,6 +17444,12 @@ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", "dev": true }, + "possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true + }, "prebuild-install": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.0.tgz", @@ -16107,15 +17506,15 @@ } }, "propagate": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", - "integrity": "sha512-T/rqCJJaIPYObiLSmaDsIf4PGA7y+pkgYFHmwoXQyOHiDDSO1YCxcztNiRBmV4EZha4QIbID3vQIHkqKu5k0Xg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, "protobufjs": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz", - "integrity": "sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.2.tgz", + "integrity": "sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==", "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -16129,13 +17528,6 @@ "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" - }, - "dependencies": { - "long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" - } } }, "proxyquire": { @@ -16182,18 +17574,14 @@ } }, "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", - "dev": true, - "requires": { - "side-channel": "^1.0.4" - } + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" }, "querystring": { "version": "0.2.0", @@ -16313,6 +17701,15 @@ } } }, + "readdirp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", + "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", + "dev": true, + "requires": { + "picomatch": "^2.0.4" + } + }, "rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -16333,14 +17730,15 @@ } }, "regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" } }, "release-zalgo": { @@ -16397,10 +17795,10 @@ "uuid": "^3.3.2" }, "dependencies": { - "qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -16416,12 +17814,12 @@ "dev": true }, "resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "requires": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } @@ -16472,22 +17870,6 @@ "dev": true, "requires": { "glob": "^7.1.3" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } } }, "run-parallel": { @@ -16500,13 +17882,13 @@ } }, "safe-array-concat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", - "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -16534,13 +17916,13 @@ } }, "safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" } }, @@ -16571,6 +17953,32 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "devOptional": true }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + } + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -16621,14 +18029,15 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "sigmund": { @@ -16661,34 +18070,24 @@ } }, "sinon": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", - "integrity": "sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", + "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==", "dev": true, "requires": { - "@sinonjs/commons": "^1.4.0", - "@sinonjs/formatio": "^3.2.1", - "@sinonjs/samsam": "^3.3.3", - "diff": "^3.5.0", - "lolex": "^4.2.0", - "nise": "^1.5.2", - "supports-color": "^5.5.0" + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^10.3.0", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.4", + "supports-color": "^7.2.0" }, "dependencies": { - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } } } }, @@ -16735,55 +18134,14 @@ "is-extendable": "^0.1.0" } }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } }, "is-extendable": { @@ -16792,12 +18150,6 @@ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -16843,6 +18195,12 @@ "kind-of": "^3.2.0" }, "dependencies": { + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -16910,20 +18268,6 @@ "which": "^1.3.0" }, "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -16955,9 +18299,9 @@ } }, "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "spdx-expression-parse": { @@ -16971,9 +18315,9 @@ } }, "spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", "dev": true }, "split-string": { @@ -17012,9 +18356,9 @@ } }, "sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -17044,75 +18388,28 @@ "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", "dev": true, "requires": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==", + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", "dev": true, "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "is-descriptor": "^0.1.0" } }, "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", "dev": true, "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" } - }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true } } }, @@ -17158,7 +18455,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", - "optional": true, + "devOptional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -17169,13 +18466,13 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "optional": true + "devOptional": true }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "optional": true, + "devOptional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -17183,36 +18480,37 @@ } }, "string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" } }, "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" } }, "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" } }, "strip-ansi": { @@ -17298,6 +18596,16 @@ "xtend": "^4.0.0" }, "dependencies": { + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "optional": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -17326,20 +18634,6 @@ "rimraf": "~2.6.2" }, "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -17361,22 +18655,6 @@ "minimatch": "^3.0.4", "read-pkg-up": "^4.0.0", "require-main-filename": "^2.0.0" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } } }, "text-table": { @@ -17385,6 +18663,13 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "requires": {} + }, "thriftrw": { "version": "3.11.4", "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.11.4.tgz", @@ -17451,13 +18736,6 @@ "integrity": "sha512-6ZMFU7AP9so+hkqmMM9fJ11V44EAcYuHCmNdsyM8k94oVnNDPQwUAAPoBHqchHSpKG6yZbCasgVeRxaY5v2BCg==", "requires": { "path-to-regexp": "^6.1.0" - }, - "dependencies": { - "path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" - } } }, "to-buffer": { @@ -17487,6 +18765,12 @@ "kind-of": "^3.0.2" }, "dependencies": { + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -17539,10 +18823,17 @@ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==" }, + "tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "dev": true, + "requires": {} + }, "tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "requires": { "@types/json5": "^0.0.29", @@ -17552,9 +18843,10 @@ } }, "tslib": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", - "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true }, "tunnel-agent": { "version": "0.6.0", @@ -17579,9 +18871,9 @@ } }, "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true }, "type-fest": { @@ -17591,50 +18883,55 @@ "dev": true }, "typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" } }, "typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dev": true, "requires": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" } }, "typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dev": true, "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" } }, "typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dev": true, "requires": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" } }, "unbox-primitive": { @@ -17655,10 +18952,10 @@ "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", "dev": true }, - "underscore": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", - "integrity": "sha512-ZqGrAgaqqZM7LGRzNjLnw5elevWb5M8LEoDMadxIW3OWbcv72wMMgKdwOKpd5Fqxe8choLD8HN3iSj3TUh/giQ==" + "undici-types": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==" }, "union-value": { "version": "1.0.1", @@ -17774,9 +19071,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "v8flags": { "version": "2.1.1", @@ -17961,25 +19258,25 @@ "optional": true }, "which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dev": true, "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" } }, "wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "optional": true, + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "devOptional": true, "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "string-width": "^1.0.2 || 2" } }, "winston": { @@ -17996,6 +19293,12 @@ "stack-trace": "0.0.x" } }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -18122,6 +19425,170 @@ "decamelize": "^1.2.0" } }, + "yargs-unparser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", + "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", + "dev": true, + "requires": { + "flat": "^4.1.0", + "lodash": "^4.17.15", + "yargs": "^13.3.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + } + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5376f1eb..48bc8407 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "f5-telemetry", - "version": "1.35.0-1", + "version": "1.36.0-1", "author": "F5, Inc.", "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/F5Networks/f5-telemetry-streaming.git" }, + "engines": { + "node": ">=8.11.1" + }, "scripts": { "install-production": "npm ci --production --no-optional", "install-test": "npm ci --no-optional", @@ -16,10 +19,12 @@ "test-functional-cloud-azure": "mocha \"./test/functional/cloud/azureTests.js\" --opts ./test/functional/.mocha.opts", "test-functional-cloud-aws": "mocha \"./test/functional/cloud/awsTests.js\" --opts ./test/functional/.mocha.opts", "test-mutation": "stryker run", - "test-only": "mocha --recursive --opts ./test/unit/.mocha.opts \"./test/unit/**/*.js\"", + "test-only-all": "./scripts/tests/unit-tests.sh", + "test-only": "SMOKE_TESTING=1 ./scripts/tests/unit-tests.sh", "test-specific": "mocha --opts ./test/unit/.mocha.opts", "test-specific-coverage": "nyc --all npm run test-specific", - "test": "nyc --all npm run test-only", + "test": "SMOKE_TESTING=1 ./scripts/tests/unit-tests.sh coverage", + "test-all": "./scripts/tests/unit-tests.sh coverage", "build": "./scripts/build/buildRpm.sh" }, "nyc": { @@ -28,15 +33,8 @@ "text", "json-summary" ], - "exclude": [ - "coverage/**", - "opensource/**", - "docs/**", - "test/**", - "scripts/**", - "examples/**", - "**/node_modules/**", - "stryker.conf.js" + "include": [ + "src/**" ] }, "dependencies": { @@ -52,45 +50,50 @@ "ajv-keywords": "^3.5.2", "applicationinsights": "^1.8.10", "aws-sdk": "2.1018.0", - "eventemitter2": "^6.4.9", + "eventemitter2": "6.4.9", "google-auth-library": "^6.1.6", + "heap": "^0.2.7", "jmespath": "^0.16.0", "json-duplicate-key-handle": "file:opensource/json-duplicate-key-handle", - "jsonwebtoken": "^8.5.1", - "kafka-node": "^2.6.1", + "jsonwebtoken": "^8", + "kafka-node": "^5.0.0", "lodash": "^4.17.21", "machina": "^4.0.2", "mustache": "^4.2.0", + "pako": "^1.0.11", "prom-client": "11.0.0", "request": "^2.88.2", "statsd-client": "^0.4.7", "tiny-request-router": "^1.2.2", - "uuid": "^3.4.0" + "uuid": "^9" }, "devDependencies": { "@f5devcentral/eslint-config-f5-atg": "^0.1.8", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "deep-diff": "^1.0.2", - "eslint": "^8.45.0", + "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.27.5", + "eslint-plugin-import": "^2.29.1", "icrdk": "git+https://git@github.com/f5devcentral/f5-icontrollx-dev-kit.git#master", - "memfs": "3.5.0", - "mocha": "^5.2.0", - "nock": "10.0.0", + "memfs": "^4", + "mocha": "^7.2.0", + "nock": "^11", "nyc": "^14.1.1", - "object.values": "^1.1.6", "proxyquire": "^2.1.3", - "sinon": "^7.5.0", + "sinon": "^15", "ssh2": "^0.8.9", - "winston": "^2.4.7" + "winston": "^2" }, "eslintConfig": { "extends": "@f5devcentral/eslint-config-f5-atg", + "parserOptions": { + "ecmaVersion": 2018 + }, "rules": { "func-names": "off", "max-classes-per-file": "off", + "no-await-in-loop": "off", "prefer-exponentiation-operator": "off", "prefer-spread": "off" } @@ -104,21 +107,19 @@ "commander": "This package dropped support for older node versions. Use v2.X.Y for Node v4.", "google-auth-library": "This package is used for GRPC connection, supports node v10 and above, but fromJSON function works in node v8.11.1 - should use v6.1.X only", "json-duplicate-key-handle": "This package is included locally to track updates and/or vulnerabilities. Included version is v1.0.0", - "jsonwebtoken": "This package dropped support for older node versions. Use v8.5.1 for Node v4.", + "jsonwebtoken": "This package dropped support for older node versions. Use v8.5.1 for Node v8.", "kafka-node": "This package dropped support for older node versions. Use v2.X.Y for Node v4.", - "prom-client": "This package dropped support for older node versions. Use v11.X.Y for Node v4. Cannot go higher than 11.0.0 because of syntax failures on test_node4.", - "uuid": "This package dropped support for older node versions starting v7.0.0. Their policy is supported node versions + one legacy version." + "prom-client": "This package dropped support for older node versions. Use v11.X.Y for Node v4. Cannot go higher than 11.0.0 because of syntax failures on test_node4." }, "devDependencies": { "@f5devcentral/eslint-config-f5-atg": "This package is updated on as-needed basis due to the work overhead.", - "chai": "This package dropped support for older node versions. Use v4.X.Y for Node v4.", - "memfs": "This package dropped support for older node versions. Use v3.5.0 for Node v4.", - "mocha": "This package should use v7.X.Y. CI installs node specific mocha version for node 4 and node 6.", - "nock": "This package dropped support for older node versions. Use v10.0.0 for Node v4.", - "nyc": "This package dropped support for older node versions. Use v14.X.Y for Node v4.", - "sinon": "This package dropped support for older node versions. Use v7.X.Y for Node v4.", + "chai": "This package dropped support for older node versions. Use v4.X.Y for Node v8.", + "mocha": "This package dropped support for older node versions. Use v7.2.0 for Node v8.", + "nock": "This package dropped support for older node versions. Use v11.X.Y for Node v8.", + "nyc": "This package dropped support for older node versions. Use v14.X.Y for Node v8.", + "sinon": "This package dropped support for older node versions. Use v15.X.Y for Node v8.", "ssh2": "This packaged dropped support for older node versions. Use v0.X.Y for Node >= v5.2 and <= v10.16", - "winston": "This package dropped support for older node versions. Use v2.4.7 for Node v4." + "winston": "This package dropped support for older node versions. Use v3.5.1 for Node v8." } }, "buildtimestamp": "buildtimestamp", diff --git a/scripts/build/buildRpm.sh b/scripts/build/buildRpm.sh index be230ee9..9d943c50 100755 --- a/scripts/build/buildRpm.sh +++ b/scripts/build/buildRpm.sh @@ -5,8 +5,8 @@ set -evx # RPM template: --..rpm # For DEV === ... -# DEV RPM: f5-telemetry-1.35.0-0.20240107071243.28507f40.dev_build_info.noarch.rpm -# Release RPM: f5-telemetry-1.35.0-0.noarch.rpm +# DEV RPM: f5-telemetry-1.36.0-0.20240107071243.28507f40.dev_build_info.noarch.rpm +# Release RPM: f5-telemetry-1.36.0-0.noarch.rpm is_release_tag () {( node -e "process.exit(+!(/^(v[0-9]+\.[0-9]+\.[0-9]+|latest)$/.test('$1')));" diff --git a/scripts/tests/unit-tests.sh b/scripts/tests/unit-tests.sh new file mode 100755 index 00000000..3b3f06a1 --- /dev/null +++ b/scripts/tests/unit-tests.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -e; # stop on errors + +startDate=$(date) +nycStart= +nycCont= + +if [ "$1" = "coverage" ]; then + echo "Coverage report will be generated at the end." + nycStart="nyc --silent" + nycCont="nyc --silent --no-clean" +fi + + +npx ${nycStart} mocha --opts ./test/unit/.mocha.opts ./test/unit/*.js + +for dir in $(ls -d ./test/unit/*/); do + npx ${nycCont} mocha --recursive --opts ./test/unit/.mocha.opts "${dir}" +done + +if [ "$1" = "coverage" ]; then + echo "Generating coverage report" + npx nyc report +fi + +echo "Started - ${startDate}" +echo "Finished - $(date)" diff --git a/src/lib/activityRecorder.js b/src/lib/activityRecorder.js deleted file mode 100644 index e9b96a35..00000000 --- a/src/lib/activityRecorder.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const constants = require('./constants'); -const logger = require('./logger').getChild('activityHistory'); -const tracer = require('./utils/tracer'); - -const PRIVATES = new WeakMap(); - -/** - * Activity Recorder class - */ -class ActivityRecorder { - constructor() { - logger.debug('New instance created'); - PRIVATES.set(this, {}); - } - - /** - * Record declaration activity - * - * @param {ConfigWorker} configWorker - * - * @returns {void} once recording configured - */ - recordDeclarationActivity(configWorker) { - logger.debug('Subscribing on configWorker events'); - configWorker.on('received', recordDeclarationActivity.bind(this, 'received')); - configWorker.on('validationFailed', recordDeclarationActivity.bind(this, 'validationFailed')); - configWorker.on('validationSucceed', recordDeclarationActivity.bind(this, 'validationSucceed')); - - const privates = PRIVATES.get(this); - privates.declarationTracer = privates.declarationTracer || tracer.create( - constants.ACTIVITY_RECORDER.DECLARATION_TRACER.PATH, - { - maxRecords: constants.ACTIVITY_RECORDER.DECLARATION_TRACER.MAX_RECORDS - } - ); - } - - /** - * Stop all recording activities - * - * @returns {void} once recording activities stopped - */ - stop() { - logger.debug('Terminating...'); - - const privates = PRIVATES.get(this); - const promises = []; - - if (privates.declarationTracer) { - logger.debug('Terminating declaration tracer'); - promises.push(privates.declarationTracer.stop()); - } - - return Promise.all(promises) - .then(() => logger.debug('Stopped!')); - } -} - -/** - * Record ConfigWorker activity - * - * @private - * @param {string} eventName - event name - * @param {object} eventData - event data - */ -function recordDeclarationActivity(eventName, eventData) { - PRIVATES.get(this).declarationTracer.write({ - event: eventName, - data: eventData - }); -} - -module.exports = ActivityRecorder; diff --git a/src/lib/appEvents.js b/src/lib/appEvents.js new file mode 100644 index 00000000..53b82cf2 --- /dev/null +++ b/src/lib/appEvents.js @@ -0,0 +1,160 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable no-plusplus */ + +'use strict'; + +const assert = require('./utils/assert'); +const logger = require('./logger').getChild('applicationEvents'); +const SafeEventEmitter = require('./utils/eventEmitter'); + +/** + * Listener Class for proxied events + * + * @private + * + * @property {ApplicationEvents} emitter + * @property {SafeEventEmitter} target + * @property {Object} events + */ +class Listener { + constructor(emitter, target, events) { + this.emitter = emitter; + this.events = events; + this.target = target; + } + + off() { + const idx = this.emitter._listeners.indexOf(this); + if (idx > -1) { + Object.keys(this.events).forEach((oevt) => { + this.emitter._ee.stopListeningTo(this.target, oevt); + this.emitter.logger.debug(`Unregistered event "${this.events[oevt]}"`); + + const tevt = this.events[oevt]; + assert.exist(this.emitter._eventsMap[tevt], `target event "${tevt}"`); + + if (this.emitter._eventsMap[tevt] <= 1) { + delete this.emitter._eventsMap[tevt]; + } else { + this.emitter._eventsMap[tevt] -= 1; + } + }); + this.emitter._listeners.splice(idx, 1); + } + return this; + } +} + +/** + * Class application events + */ +class ApplicationEvents { + constructor() { + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + logger: { + value: logger + } + }); + + this._ee = new SafeEventEmitter({ wildcard: true }); + this._ee.logger = this.logger; + this._listeners = []; + this._eventsMap = {}; + } + + /** @returns {string[]} list of registered events */ + get registeredEvents() { + return Object.keys(this._eventsMap); + } + + /** + * Adds a listener to the end of the listeners array for the specified event + * + * @see eventEmitter2.prototype.on() for params and return info + */ + on(...rest) { + return this._ee.on(...rest); + } + + /** + * Register external emitter + * + * @param {SafeEventEmitter} target + * @param {string} namespace + * @param {string[] | Object[]} events + * + * @returns {function} callback to call to stop events proxying + */ + register(target, namespace, events) { + assert.string(namespace, 'namespace'); + assert.array(events, 'events'); + assert.not.empty(events, 'events'); + + const map = {}; + events.forEach((evt) => { + let oevt; + let tevt; + + if (typeof evt === 'object') { + [oevt, tevt] = Object.entries(evt)[0]; + } else { + oevt = evt; + tevt = evt; + } + + assert.string(oevt, 'origin event'); + assert.string(tevt, 'target event'); + + tevt = `${namespace}.${tevt}`; + map[oevt] = tevt; + + this._eventsMap[tevt] = (this._eventsMap[tevt] || 0) + 1; + }); + + Object.values(map) + .forEach((event) => this.logger.debug(`Registered event "${event}"`)); + + this._ee.listenTo(target, map, { + reducers: (event) => { + this.logger.debug(`Emitting event "${event.name}" (${event.original})`); + } + }); + + return this._listeners[this._listeners.push(new Listener(this, target, map)) - 1]; + } + + /** + * Stop listening and proxying all events + */ + stop() { + this._ee.removeAllListeners(); + this._listeners.forEach((listener) => listener.off()); + this._listeners = []; + } + + /** + * Returns a thenable object (promise interface) that resolves when a specific event occurs + * + * @see eventEmitter2.prototype.waitFor() for params and return info + */ + waitFor(...rest) { + return this._ee.waitFor(...rest); + } +} + +module.exports = ApplicationEvents; diff --git a/src/lib/config.js b/src/lib/config.js index 528a937a..619620cf 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -16,17 +16,21 @@ 'use strict'; +const assert = require('./utils/assert'); const CONFIG_CLASSES = require('./constants').CONFIG_CLASSES; const CONFIG_WORKER = require('./constants').CONFIG_WORKER; const configUtil = require('./utils/config'); const errors = require('./errors'); const logger = require('./logger'); -const persistentStorage = require('./persistentStorage').persistentStorage; const SafeEventEmitter = require('./utils/eventEmitter'); -const TeemReporter = require('./teemReporter').TeemReporter; const util = require('./utils/misc'); -/** @module config */ +/** + * @module config + * + * @typedef {import('./utils/config').Component} Component + * @typedef {import('./utils/config').FilterOptions} FilterOptions + */ const BASE_CONFIG = { components: [], @@ -47,7 +51,6 @@ const BASE_STORAGE_DATA = {}; * * @property {configUtil.Configuration} currentConfig - copy of current configuration * @property {logger.Logger} logger - logger instance - * @property {TeemReporter} teemReporter - TeemReporter instance * @property {object} validators - AJV validators */ class ConfigWorker extends SafeEventEmitter { @@ -71,18 +74,6 @@ class ConfigWorker extends SafeEventEmitter { return this.logger; } - /** - * @public - * @returns {TeemReporter} instance - */ - get teemReporter() { - // lazy initialization - Object.defineProperty(this, 'teemReporter', { - value: new TeemReporter() - }); - return this.teemReporter; - } - /** * @public * @returns {object} AJV validators @@ -99,11 +90,14 @@ class ConfigWorker extends SafeEventEmitter { * Cleanup current state * * @public - * @returns {Promise} resolved once data removed from storage + * @returns {void} once data removed from storage */ - cleanup() { + async cleanup() { delete this._currentConfig; - return persistentStorage.remove(CONFIG_WORKER.STORAGE_KEY); + + await new Promise((resolve) => { + this.emitAsync('storage.remove', CONFIG_WORKER.STORAGE_KEY, resolve); + }); } /** @@ -239,6 +233,13 @@ class ConfigWorker extends SafeEventEmitter { // ensure that 'validatedConfig' is a copy validatedConfig = util.deepCopy(config); storageData.raw = util.deepCopy(config); + return this.safeEmitAsync('prevalidationSucceed', { + declaration: util.deepCopy(validatedConfig), + metadata: util.deepCopy(options.metadata), + transactionID + }); + }) + .then(() => { this.logger.debug('Expanding configuration'); return expandDeclaration.call(this, declaration); }) @@ -285,28 +286,32 @@ class ConfigWorker extends SafeEventEmitter { return Promise.resolve(); }) .then(() => notifyConfigChange.call(this, this.currentConfig, setConfigOpts)) - .then(() => { - this.teemReporter.process(validatedConfig); - return options.expanded ? expandedConfig : validatedConfig; - }); + .then(() => (options.expanded ? expandedConfig : validatedConfig)); } } /** * @this ConfigWorker - * @returns {Promise} resolved with data loaded from Persistent Storage + * @returns {object} data loaded from Persistent Storage */ -function getStorageData() { - // persistentStorage.get returns data copy - return persistentStorage.get(CONFIG_WORKER.STORAGE_KEY) - .then((data) => { - // NOTE: starting from 1.19 '.raw' stored only - if (typeof data === 'undefined') { - data = util.deepCopy(BASE_STORAGE_DATA); - this.logger.debug(`persistentStorage did not have a value for ${CONFIG_WORKER.STORAGE_KEY}`); +async function getStorageData() { + let data = await new Promise((resolve, reject) => { + this.emitAsync('storage.get', CONFIG_WORKER.STORAGE_KEY, (error, value) => { + if (error) { + reject(error); + } else { + resolve(value); } - return data; }); + }); + + if (typeof data === 'undefined') { + data = util.deepCopy(BASE_STORAGE_DATA); + this.logger.debug('No pre-existing configuration. Using the default one.'); + } + + // the storage returns data copy + return data; } /** @@ -362,18 +367,18 @@ function notifyConfigChange(newConfig, options) { /** * @this ConfigWorker + * * @param {object} storageData - data to save to Persistent Storage * - * @returns {Promise} resolved once config is saved + * @returns {void} once config is saved */ -function saveToStorage(storageData) { - // persistentStorage.set will make copy of data - return persistentStorage.set(CONFIG_WORKER.STORAGE_KEY, storageData) - .then(() => this.logger.debug('Application config saved')) - .catch((err) => { - this.logger.exception('Unexpected error on attempt to save application state', err); - return Promise.reject(err); - }); +async function saveToStorage(storageData) { + // the storage will make copy of data + await new Promise((resolve) => { + this.emitAsync('storage.set', CONFIG_WORKER.STORAGE_KEY, storageData, resolve); + }); + + this.logger.debug('Application config saved'); } /** @@ -395,11 +400,55 @@ function validate(declaration, options) { if (typeof validatorFunc !== 'undefined') { // AJV validators mutates 'declaration' return configUtil.validate(validatorFunc, declaration, options.context) - .catch((err) => Promise.reject(new errors.ValidationError(err))); + .catch((err) => Promise.reject(new errors.ValidationError(err.message))); } return Promise.reject(new Error('Validator is not available')); } +/** + * NOTE: mutates `config` + * + * @param {Component | Component[]} config + * @param {function(error, config: Component | Component[])} callback + */ +async function onDecryptConfig(config, callback) { + try { + callback(null, await configUtil.decryptSecrets(config)); + } catch (error) { + callback(error); + } +} + +/** + * @param {function(result: Component[])} callback + * @param {FilterOptions} [filter] filtering options. 'filter' property ignored. + */ +function onGetConfig(callback, filter = {}) { + delete filter.filter; + // currentConfig returns a copy every time + callback(configUtil.getComponents(this.currentConfig, filter)); +} + +/** + * @param {Component | Component[]} config + * @param {function(error, hash: string | string[])} callback + */ +async function onGetHash(config, callback) { + const isArray = Array.isArray(config); + config = isArray ? config : [config]; + + try { + assert.not.empty(config, 'config'); + const hash = config.map((c) => { + assert.object(c, 'config'); + return configUtil.getComponentHash(c); + }); + callback(null, isArray ? hash : hash[0]); + } catch (error) { + callback(error); + } +} + // initialize singleton const configWorker = new ConfigWorker(); configWorker.setMaxListeners(20); @@ -419,7 +468,26 @@ configWorker.on('error', (err) => { configWorker.logger.exception('Unhandled error in ConfigWorker', err); }); +function initialize(appEvents) { + const namespace = 'config'; + appEvents.register(configWorker, namespace, [ + { change: 'change' }, + { prevalidationSucceed: 'prevalidated' }, + { received: 'received' }, + { validationFailed: 'validationFailed' }, + { validationSucceed: 'validationSucceed' }, + 'storage.get', + 'storage.remove', + 'storage.set' + ]); + + appEvents.on(`*.${namespace}.decrypt`, onDecryptConfig.bind(configWorker), { objectify: true }); + appEvents.on(`*.${namespace}.getConfig`, onGetConfig.bind(configWorker), { objectify: true }); + appEvents.on(`*.${namespace}.getHash`, onGetHash.bind(configWorker), { objectify: true }); +} + module.exports = configWorker; +module.exports.initialize = initialize; /** * Config changed event. diff --git a/src/lib/constants.js b/src/lib/constants.js index 20904c50..e037eb5c 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -60,14 +60,7 @@ module.exports = { }, APP_NAME: 'Telemetry Streaming', APP_THRESHOLDS: { - MONITOR_DISABLED: 'MONITOR_DISABLED', // TODO: delete MEMORY: { - /** TODO: DELETE */ - DEFAULT_MB: 1433, - OK: 'MEMORY_USAGE_OK', - NOT_OK: 'MEMORY_USAGE_HIGH', - /** TODO: DELETE END */ - ARGRESSIVE_CHECK_INTERVALS: [ { usage: 50, interval: 0.5 }, { usage: 60, interval: 0.4 }, @@ -130,18 +123,20 @@ module.exports = { CONFIG_WORKER: { STORAGE_KEY: 'config' }, + DATA_PIPELINE: { + PULL_EVENT: 0b01, + PUSH_EVENT: 0b10, + PUSH_PULL_EVENT: 0b11 + }, DAY_NAME_TO_WEEKDAY, DEVICE_REST_API: { + CHUNK_SIZE: 512 * 1024, PORT: 8100, PROTOCOL: 'http', TRANSFER_FILES: { BULK: { DIR: '/var/config/rest/bulk', URI: '/mgmt/shared/file-transfer/bulk/' - }, - MADM: { - DIR: '/var/config/rest/madm', - URI: '/mgmt/shared/file-transfer/madm/' } }, USER: 'admin' @@ -182,10 +177,13 @@ module.exports = { IHEALTH_POLLER: 'ihealthInfo' }, HTTP_REQUEST: { + ALLOWED_PROTOCOLS: ['http', 'https'], DEFAULT_PORT: 80, DEFAULT_PROTOCOL: 'http' }, IHEALTH: { + DEMO_CLEANUP_TIMEOUT: 5 * 60 * 1000, // 5 min. + MAX_HISTORY_LEN: 20, POLLER_CONF: { QKVIEW_COLLECT: { DELAY: 2 * 60 * 1000, // 2 min. @@ -204,10 +202,12 @@ module.exports = { MAX_PAST_DUE: 2 * 60 * 60 * 1000 // 2 hours } }, + SECRETS_TIMEOUT: 60 * 1000, // 1 min. SERVICE_API: { - LOGIN: 'https://api.f5.com/auth/pub/sso/login/ihealth-api', - UPLOAD: 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews' + LOGIN: 'https://identity.account.f5.com/oauth2/ausp95ykc80HOU7SQ357/v1/token', + UPLOAD: 'https://ihealth2-api.f5.com/qkview-analyzer/api/qkviews' }, + SLEEP_INTERVAL: 120 * 1000, // max sleep interval per iteration for `waiting` state STORAGE_KEY: 'ihealth' }, LOCAL_HOST: 'localhost', @@ -226,6 +226,18 @@ module.exports = { }, STATS_KEY_SEP: '::', STRICT_TLS_REQUIRED: true, + SYSTEM_POLLER: { + CHUNK_SIZE: 30, + DEMO_CLEANUP_TIMEOUT: 5 * 60 * 1000, // 5 min. + MAX_HISTORY_LEN: 40, + SECRETS_TIMEOUT: 60 * 1000, // 1 min. + SLEEP_INTERVAL: 120 * 1000, // max sleep interval per iteration for `waiting` state + WORKERS: 5 + }, + TASK: { + LOW_PRIORITY: 10, + HIGH_PRIORITY: 1 + }, TRACER: { DIR: '/var/tmp/telemetry', ENCODING: 'utf8', diff --git a/src/lib/consumers.js b/src/lib/consumers.js deleted file mode 100644 index 7486f7ec..00000000 --- a/src/lib/consumers.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const path = require('path'); - -const logger = require('./logger'); -const util = require('./utils/misc'); -const tracerMgr = require('./tracerManager'); -const moduleLoader = require('./utils/moduleLoader').ModuleLoader; -const metadataUtil = require('./utils/metadata'); -const constants = require('./constants'); -const configWorker = require('./config'); -const configUtil = require('./utils/config'); -const DataFilter = require('./dataFilter').DataFilter; -const promiseUtil = require('./utils/promise'); - -const CONSUMERS_DIR = '../consumers'; -let CONSUMERS = []; - -/** -* Load plugins for requested consumers -* -* @param {Object} config - config object -* @param {Array} config.consumers - array of consumers to load -* @param {string} config.consumers[].consumer - consumer name/type -* -* @returns {Object} Promise object with resolves with array of - loaded plugins. Looks like following: - [ - { - consumer: function(context), - config: [object] - }, - ... - ] -*/ -function loadConsumers(config) { - if (config.length === 0) { - logger.info('No consumer(s) to load, define in configuration first'); - return Promise.resolve([]); - } - - const enabledConsumers = config.filter((c) => c.enable); - if (enabledConsumers.length === 0) { - logger.debug('No enabled consumer(s) to load'); - return Promise.resolve([]); - } - - logger.debug(`Loading consumer specific plug-ins from ${CONSUMERS_DIR}`); - const loadPromises = enabledConsumers.map((consumerConfig) => new Promise((resolve) => { - const existingConsumer = CONSUMERS.find((c) => c.id === consumerConfig.id); - if (consumerConfig.skipUpdate && existingConsumer) { - resolve(existingConsumer); - } else { - const consumerType = consumerConfig.type; - const consumerDir = (path.join(CONSUMERS_DIR, consumerType)); - logger.debug(`Loading consumer ${consumerType} plug-in from ${consumerDir}`); - const consumerModule = moduleLoader.load(consumerDir); - if (consumerModule === null) { - resolve(undefined); - } else { - const consumer = { - name: consumerConfig.name, - id: consumerConfig.id, - config: util.deepCopy(consumerConfig), - consumer: consumerModule, - tracer: tracerMgr.fromConfig(consumerConfig.trace), - filter: new DataFilter(consumerConfig) - }; - consumer.config.allowSelfSignedCert = consumer.config.allowSelfSignedCert === undefined - ? !constants.STRICT_TLS_REQUIRED : consumer.config.allowSelfSignedCert; - metadataUtil.getInstanceMetadata(consumer) - .then((metadata) => { - if (!util.isObjectEmpty(metadata)) { - consumer.metadata = metadata; - } - // copy consumer's data - resolve(consumer); - }); - } - } - })); - return promiseUtil.allSettled(loadPromises) - .then((results) => promiseUtil.getValues(results).filter((c) => c !== undefined)); -} - -/** - * Get set of loaded Consumers' types - * - * @returns {Set} set with loaded Consumers' types - */ -function getLoadedConsumerTypes() { - if (CONSUMERS.length > 0) { - return new Set(CONSUMERS.map((consumer) => consumer.config.type)); - } - return new Set(); -} - -/** - * Unload unused modules from cache - * - * @param {Set} before - set of Consumers' types before - */ -function unloadUnusedModules(before) { - if (!before.size) { - return; - } - const loadedTypes = getLoadedConsumerTypes(); - before.forEach((consumerType) => { - if (!loadedTypes.has(consumerType)) { - logger.debug(`Unloading Consumer module '${consumerType}'`); - const consumerDir = path.join(CONSUMERS_DIR, consumerType); - - moduleLoader.unload(consumerDir); - } - }); -} - -// config worker change event -configWorker.on('change', (config) => Promise.resolve() - .then(() => { - logger.debug('configWorker change event in consumers'); - - const consumersToLoad = configUtil.getTelemetryConsumers(config); - const typesBefore = getLoadedConsumerTypes(); - return loadConsumers(consumersToLoad) - .then((consumers) => { - CONSUMERS = consumers; - logger.info(`${CONSUMERS.length} consumer plug-in(s) loaded`); - }) - .catch((err) => { - logger.exception('Unhandled exception when loading consumers', err); - }) - .then(() => unloadUnusedModules(typesBefore)); - })); - -module.exports = { - getConsumers: () => CONSUMERS -}; diff --git a/src/lib/consumers/DataDog/index.js b/src/lib/consumers/DataDog/index.js index f88f11b3..ed94a474 100644 --- a/src/lib/consumers/DataDog/index.js +++ b/src/lib/consumers/DataDog/index.js @@ -20,12 +20,11 @@ 'use strict'; -const https = require('https'); const zlib = require('zlib'); const DEFAULT_HOSTNAME = require('../../constants').DEFAULT_HOSTNAME; const EVENT_TYPES = require('../../constants').EVENT_TYPES; -const httpUtil = require('../shared/httpUtil'); +const httpUtil = require('../../utils/http'); const metricsUtil = require('../shared/metricsUtil'); const miscUtil = require('../../utils/misc'); const promiseUtil = require('../../utils/promise'); @@ -128,42 +127,14 @@ const wrapChunks = (chunks, ddType) => { return chunks.map((c) => `{"series":[${c.join('')}]}`); }; -/** - * Fetch custom options for HTTP transport from config - * - * @param {Array} customOpts - options from config - * - * @returns {Object} - */ -const fetchHttpCustomOpts = (customOpts) => { - const allowedKeys = [ - 'keepAlive', - 'keepAliveMsecs', - 'maxSockets', - 'maxFreeSockets' - ]; - const ret = {}; - customOpts.filter((opt) => allowedKeys.indexOf(opt.name) !== -1) - .forEach((opt) => { - ret[opt.name] = opt.value; - }); - return ret; -}; - const httpAgentsMap = {}; -const createHttpAgentOptsKey = (opts) => { - const keys = Object.keys(opts); - keys.sort(); - return JSON.stringify(keys.map((k) => [k, opts[k]])); -}; const getHttpAgent = (config) => { - const customOpts = fetchHttpCustomOpts(config.customOpts || []); - const optsKey = createHttpAgentOptsKey(customOpts); - if (!httpAgentsMap[config.id] || httpAgentsMap[config.id].key !== optsKey) { + const agentFromConf = httpUtil.getAgent(config); + if (!httpAgentsMap[config.id] || httpAgentsMap[config.id].key !== agentFromConf.agentKey) { httpAgentsMap[config.id] = { - agent: new https.Agent(Object.assign({}, customOpts)), - key: optsKey + key: agentFromConf.agentKey, + agent: agentFromConf.agent }; } return httpAgentsMap[config.id].agent; @@ -192,7 +163,8 @@ module.exports = function (context) { result[tag.name] = tag.value; return result; }, {}); - const httpAgentOpts = fetchHttpCustomOpts(context.config.customOpts || []); + const httpAgentOpts = httpUtil.getAgentOpts(context.config); + const httpAgent = getHttpAgent(Object.assign({ connection: { protocol: DATA_DOG_PROTOCOL } }, context.config)); // for now use current time, ideally should try to fetch it from event data const timestamp = Math.floor(Date.now() / 1000); const maxChunkSize = needGzip ? DATA_DOG_MAX_GZIP_CHUNK_SIZE : DATA_DOG_MAX_CHUNK_SIZE; @@ -350,7 +322,7 @@ module.exports = function (context) { return promiseUtil.allSettled([ promiseUtil.loopForEach(ddData, (dataChunk) => compressDataFn(dataChunk) .then((compressedData) => httpUtil.sendToConsumer({ - agent: getHttpAgent(context.config), + agent: httpAgent, allowSelfSignedCert, body: compressedData, expectedResponseCode: [200, 202], @@ -373,7 +345,7 @@ module.exports = function (context) { })), promiseUtil.loopForEach(ddAuxData, (dataChunk) => compressAuxDataFn(dataChunk) .then((compressedData) => httpUtil.sendToConsumer({ - agent: getHttpAgent(context.config), + agent: httpAgent, allowSelfSignedCert, body: compressedData, expectedResponseCode: [200, 202], diff --git a/src/lib/consumers/ElasticSearch/index.js b/src/lib/consumers/ElasticSearch/index.js index 87bf0d50..e3699926 100644 --- a/src/lib/consumers/ElasticSearch/index.js +++ b/src/lib/consumers/ElasticSearch/index.js @@ -16,7 +16,7 @@ 'use strict'; -const httpUtil = require('../shared/httpUtil'); +const httpUtil = require('../../utils/http'); const util = require('../../utils/misc'); const EVENT_TYPES = require('../../constants').EVENT_TYPES; diff --git a/src/lib/consumers/Generic_HTTP/index.js b/src/lib/consumers/Generic_HTTP/index.js index 6c53be98..d682fe03 100644 --- a/src/lib/consumers/Generic_HTTP/index.js +++ b/src/lib/consumers/Generic_HTTP/index.js @@ -16,11 +16,9 @@ 'use strict'; -const http = require('http'); -const https = require('https'); const zlib = require('zlib'); -const httpUtil = require('../shared/httpUtil'); +const httpUtil = require('../../utils/http'); const util = require('../../utils/misc'); /** @@ -57,42 +55,14 @@ const maybeCompress = (compression, data) => { return compressData(compression, data); }; -/** - * Fetch custom options for HTTP transport from config - * - * @param {Array} customOpts - options from config - * - * @returns {Object} - */ -const fetchHttpCustomOpts = (customOpts) => { - const allowedKeys = [ - 'keepAlive', - 'keepAliveMsecs', - 'maxSockets', - 'maxFreeSockets' - ]; - const ret = {}; - customOpts.filter((opt) => allowedKeys.indexOf(opt.name) !== -1) - .forEach((opt) => { - ret[opt.name] = opt.value; - }); - return ret; -}; - const httpAgentsMap = {}; -const createHttpAgentOptsKey = (opts) => { - const keys = Object.keys(opts); - keys.sort(); - return JSON.stringify(keys.map((k) => [k, opts[k]])); -}; const getHttpAgent = (config) => { - const customOpts = fetchHttpCustomOpts(config.customOpts || []); - const optsKey = createHttpAgentOptsKey(customOpts); - if (!httpAgentsMap[config.id] || httpAgentsMap[config.id].key !== optsKey) { + const agentFromConf = httpUtil.getAgent(config); + if (!httpAgentsMap[config.id] || httpAgentsMap[config.id].key !== agentFromConf.agentKey) { httpAgentsMap[config.id] = { - agent: new (config.protocol === 'https' ? https.Agent : http.Agent)(Object.assign({}, customOpts)), - key: optsKey + key: agentFromConf.agentKey, + agent: agentFromConf.agent }; } return httpAgentsMap[config.id].agent; @@ -169,7 +139,7 @@ module.exports = function (context) { return maybeCompress(compressionType, body) .then((data) => httpUtil.sendToConsumer({ - agent: getHttpAgent(context.config), + agent: getHttpAgent(Object.assign({ connection: { protocol } }, context.config)), allowSelfSignedCert, body: data, hosts: [host].concat(fallbackHosts), diff --git a/src/lib/consumers/Kafka/index.js b/src/lib/consumers/Kafka/index.js index 971656d4..6827e60d 100644 --- a/src/lib/consumers/Kafka/index.js +++ b/src/lib/consumers/Kafka/index.js @@ -16,14 +16,20 @@ 'use strict'; +// This uses the original kafka-node library, but is the latest version with breaking changes. +// This is for Kafka deployments running in KRaft mode. +// Zookeeper-based configurations should use the legacy library used in TS versions prior to 1.36. +// Zookeeper has been deprecated as of Kafka 3.5 and will be removed in 4.0 + const kafka = require('kafka-node'); +const constants = require('../../constants'); +const PARTITIONER_TYPES = ['default', 'random', 'cyclic', 'keyed']; /** * AUTOTOOL-491 workaround to resolve following situation: * - connection to broker was removed from pool already - assertion failed * - another connection was registered with the same key (addr:port) - assertion failed * - * source: kafka-node v2.6.1, kafkaClient.js, line 669 */ if (typeof kafka.KafkaClient.prototype.deleteDisconnected !== 'undefined') { const originalMethod = kafka.KafkaClient.prototype.deleteDisconnected; @@ -39,7 +45,7 @@ if (typeof kafka.KafkaClient.prototype.deleteDisconnected !== 'undefined') { /** * Construct Kafka client options from Consumer config * - * @param {String} kafkaHost Connection string to Kafka host in 'kafka-host1:9092' format + * @param {String} kafkaHost Connection string to Kafka host(s) * @param {Object} config Consumer configuration object * * @returns {Object} Kafka Client options object @@ -52,7 +58,7 @@ function buildKafkaClientOptions(kafkaHost, config) { rejectUnauthorized: !config.allowSelfSignedCert }; } - // Auth options + let saslOptions = null; if (config.authenticationProtocol === 'SASL-PLAIN') { saslOptions = { @@ -68,13 +74,147 @@ function buildKafkaClientOptions(kafkaHost, config) { }); } - return { + const defaulRetryOpts = { + retries: 5, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true + }; + + const clientOpts = { kafkaHost, connectTimeout: 3 * 1000, // shorten timeout requestTimeout: 5 * 1000, // shorten timeout sslOptions: tlsOptions, - sasl: saslOptions + sasl: saslOptions, + connectRetryOptions: defaulRetryOpts }; + + // allow additional opts or overrides to pass to client lib so we don't have to expose all via schema + if (config.customOpts && config.customOpts.length) { + let allowedKeys = [ + 'connectTimeout', + 'requestTimeout', + 'idleConnection', + 'maxAsyncRequests' + ]; + + allowedKeys = allowedKeys.concat(Object.keys(defaulRetryOpts).map((k) => `connectRetryOptions.${k}`)); + + config.customOpts.forEach((opt) => { + if (allowedKeys.indexOf(opt.name) > -1) { + if (opt.name.startsWith('connectRetryOptions')) { + const prop = opt.name.substring(opt.name.indexOf('.') + 1); + clientOpts.connectRetryOptions[prop] = opt.value; + } else { + clientOpts[opt.name] = opt.value; + } + } + }); + } + return clientOpts; +} + +/** + * Generate string containing kafka host(s) to connect to + * + * @param {Object} config Consumer config + * + * @returns {String} Host(s) to pass to KafkaClient in format ${host}:${port} + * Comma is used as delimiter for multiple hosts + */ +function buildKafkaHostString(config) { + if (typeof config.host === 'string') { + return `${config.host}:${config.port || 9092}`; + } + + let hostStr = ''; + config.host.forEach((item, idx) => { + const delimiter = idx === 0 ? '' : ','; + hostStr += `${delimiter}${item}:${config.port || 9092}`; + }); + + return hostStr; +} + +/** + * Parses the event data to send to Kafka broker + * Option to split up sytem poller data into multiple messages to decrease individual message size + * + * @param {Object} context Consumer context containing config and data + * + * @returns {Array|Array} List of strings containing data to send. + * If using keyed partition, a KeyedMessage is created. + * If 'split' format is specified, the messages are formatted like below: + * Sample data input: + * { + * system: { hostname: 'somehost', version: '15.1.0', tmmTraffic: { 'clientSideTraffic.bitsIn' : 100 } }, + * virtualServers: { + * 'virtual1': { + * 'serverside.bitsIn': 111, + * enabledState: 'enabled' + * }, + * virtual2: { + * 'serverside.bitsIn': 222, + * 'enabledState': 'enabled' + * } + * } + * } + * Expected output: + * [ + * '{ "system": {"hostname":"somehost","version":"15.1.0","tmmTraffic":{"clientSideTraffic.bitsIn":100}}}', + * '{ "system": {"hostname":"somehost"}, + * "virtualServers": {"virtual1":{"serverside.bitsIn": 111,enabledState: 'enabled'}} + * }', + * '{ "system": {"hostname":"somehost"}, + * "virtualServers": {"virtual2":{"serverside.bitsIn": 222,enabledState: 'enabled'}} + * }' + * ] + */ +function buildMessages(context) { + const data = context.event.data; + const eventType = context.event.type; + const partitionKey = context.config.partitionKey; + const isKeyed = context.config.partitionerType === 'keyed' && partitionKey; + + // for RAW_EVENTS / pre-formatted + if (typeof data === 'string') { + const message = isKeyed ? new kafka.KeyedMessage(partitionKey, data) : data; + return [message]; + } + + if (context.config.format === 'default' || eventType !== constants.EVENT_TYPES.SYSTEM_POLLER) { + const message = JSON.stringify(data); + return isKeyed ? [new kafka.KeyedMessage(partitionKey, message)] : [message]; + } + + // Additional processing for System Poller Info + // Split other properties into separate messages + const hostname = data.system.hostname || constants.DEFAULT_HOSTNAME; + context.logger.verbose(`Building messages for ${hostname} using "split" format.`); + const messageList = []; + + Object.keys(data).forEach((key) => { + if (key === 'system') { + messageList.push(JSON.stringify({ system: data.system })); + } else { + const nonSystemProp = data[key]; + Object.keys(nonSystemProp).forEach((propKey) => { + const message = { + system: { hostname }, + [key]: {} + }; + message[key][propKey] = nonSystemProp[propKey]; + messageList.push(JSON.stringify(message)); + }); + } + }); + + if (context.config.partitionKey) { + return messageList.map((msg) => new kafka.KeyedMessage(context.config.partitionKey, msg)); + } + return messageList; } /** @@ -91,12 +231,13 @@ class ConnectionCache { * Creates and returns an object containing a Kafka Client and Producer. * If a cached connection exists, the cached connection is returned. * - * @param {Object} config Consumer configuration object + * @param {Object} context Consumer context * * @returns {Object} Object containing the Kafka Client and Producer */ - makeConnection(config) { - const kafkaHost = `${config.host}:${config.port || 9092}`; // format: 'kafka-host1:9092' + makeConnection(context) { + const config = context.config; + const kafkaHost = buildKafkaHostString(config); const cacheEntry = this.connectionCache[kafkaHost]; if (typeof cacheEntry !== 'undefined') { @@ -110,8 +251,12 @@ class ConnectionCache { context.logger.exception('Unexpected error in KafkaClient', err); }); - const producer = new kafka.Producer(client); + const producer = new kafka.Producer( + client, + { partitionerType: PARTITIONER_TYPES.indexOf(context.config.partitionerType) } + ); producer.on('ready', () => { + context.logger.verbose(`KafkaClient successfully connected to ${kafkaHost}.`); const connection = { client, producer @@ -136,22 +281,24 @@ const connectionCache = new ConnectionCache(); * See {@link ../README.md#context} for documentation */ module.exports = function (context) { - const config = context.config; return Promise.resolve() - .then(() => connectionCache.makeConnection(config)) + .then(() => connectionCache.makeConnection(context)) .then((connection) => { - const payload = [ - { - topic: config.topic, - messages: JSON.stringify(context.event.data) - } - ]; + const payload = { + topic: context.config.topic, + messages: buildMessages(context) + }; + + // only pass key if exists, will only exist if schema validated that partitionerType = keyed + if (context.config.partitionKey) { + payload.key = context.config.partitionKey; + } if (context.tracer) { context.tracer.write(payload); } - connection.producer.send(payload, (error) => { + connection.producer.send([payload], (error) => { if (error) { context.logger.error(`error: ${error.message ? error.message : error}`); } else { diff --git a/src/lib/consumers/OpenTelemetry_Exporter/index.js b/src/lib/consumers/OpenTelemetry_Exporter/index.js index fefbde91..8f55b888 100644 --- a/src/lib/consumers/OpenTelemetry_Exporter/index.js +++ b/src/lib/consumers/OpenTelemetry_Exporter/index.js @@ -24,7 +24,7 @@ const MetricReader = require('@opentelemetry/sdk-metrics').MetricReader; const ProtobufMetricExporter = require('@opentelemetry/exporter-metrics-otlp-proto').OTLPMetricExporter; const OTELApi = require('@opentelemetry/api'); -const httpUtil = require('../shared/httpUtil'); +const httpUtil = require('../../utils/http'); const metricsUtil = require('../shared/metricsUtil'); const miscUtil = require('../../utils/misc'); diff --git a/src/lib/consumers/Prometheus/index.js b/src/lib/consumers/Prometheus/index.js new file mode 100644 index 00000000..5dec0dee --- /dev/null +++ b/src/lib/consumers/Prometheus/index.js @@ -0,0 +1,136 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const promClient = require('prom-client'); + +const API = require('../api'); +const utils = require('./utils'); + +/** + * @module consumers/prometheus + * + * @typedef {import('../api').ConsumerCallback} ConsumerCallback + * @typedef {import('../api').ConsumerConfig} ConsumerConfig + * @typedef {import('../api').ConsumerInterface} ConsumerInterface + * @typedef {import('../api').ConsumerModuleInterface} ConsumerModuleInterface + * @typedef {import('../../dataPipeline').DataEventCtxV2} DataCtx + */ + +/** + * @param {DataCtx[]} dataCtx + * @param {ConsumerCallback} callback + */ +function processData(dataCtxs, callback) { + // Create a custom metric registry each time, as the default registry will persist across API calls + const registry = new promClient.Registry(); + + // Collect metrics from event + const collectedMetrics = utils.collectMetrics(dataCtxs); + const prioritizedMetrics = utils.prioritizeCollectedMetrics(collectedMetrics); + + const orderedPriorityKeys = Object.keys(prioritizedMetrics).sort((a, b) => a - b); + + orderedPriorityKeys.forEach((priorityKey) => { + prioritizedMetrics[priorityKey].forEach((metricObject) => { + let metricName = `f5_${metricObject.metricName}`; + // Check if already registered + if (typeof registry.getSingleMetric(metricName) !== 'undefined') { + metricName = `f5_${utils.toPrometheusMetricFormat(metricObject.originalMetricName, true).metricName}`; + } + try { + const gauge = new promClient.Gauge({ + name: metricName, + help: metricObject.originalMetricName, + registers: [registry], + labelNames: metricObject.labelNames + }); + metricObject.labels.forEach((labelObj) => { + gauge.set(labelObj.labels, labelObj.value); + }); + } catch (err) { + this.logger.exception(`Unable to register metric for: ${metricObject.originalMetricName}:`, err); + } + }); + }); + + // Return metrics from the registry + const output = registry.metrics(); + this.logger.verbose('success'); + + this.writeTraceData(output); + callback(null, { + contentType: promClient.contentType, + data: output + }); +} + +/** + * Telemetry Streaming Prometheus Pull Consumer + * + * @implements {ConsumerInterface} + */ +class PrometheusConsumer extends API.Consumer { + /** @inheritdoc */ + get allowsPull() { + return true; + } + + /** @inheritdoc */ + get allowsPush() { + return false; + } + + /** @inheritdoc */ + onData(dataCtxs, emask, callback) { + try { + processData.call(this, dataCtxs, callback); + } catch (error) { + this.logger.exception('Uncaught error on attempt to process data', error); + callback(error); + } + } +} + +/** + * Telemetry Streaming Prometheus Consumer Module + * + * @implements {ConsumerModuleInterface} + */ +class PrometheusConsumerModule extends API.ConsumerModule { + /** @inheritdoc */ + async createConsumer() { + return new PrometheusConsumer(); + } +} + +/** + * Load Telemetry Streaming Prometheus Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + async load() { + return new PrometheusConsumerModule(); + } +}; diff --git a/src/lib/pullConsumers/Prometheus/index.js b/src/lib/consumers/Prometheus/utils.js similarity index 64% rename from src/lib/pullConsumers/Prometheus/index.js rename to src/lib/consumers/Prometheus/utils.js index 9c5f752a..1a41a8d1 100644 --- a/src/lib/pullConsumers/Prometheus/index.js +++ b/src/lib/consumers/Prometheus/utils.js @@ -16,10 +16,15 @@ 'use strict'; -const promClient = require('prom-client'); const mergeObjectArray = require('../../utils/misc').mergeObjectArray; const deepCopy = require('../../utils/misc').deepCopy; +/** + * @module consumers/prometheus/utils + * + * @typedef {import('../../../dataPipeline').DataEventCtxV2} DataCtx + */ + const KEYS_TO_HOIST = { system: [ 'diskStorage', @@ -56,95 +61,17 @@ const KEYS_FOR_LABELLING = [ 'srvWideIps' ]; -/** - * Apply consumer-specific formatting, and return the reformatted data - * - * @param {Object} context - complete context object - * @param {Object} context.config - consumer's config from declaration - * @param {Object} context.logger - logger instance - * @param {function(string):void} context.logger.info - log info message - * @param {function(string):void} context.logger.error - log error message - * @param {function(string):void} context.logger.debug - log debug message - * @param {function(string, err):void} context.logger.exception - log error message with error's traceback - * @param {Array} context.event - array of events to process - * @param {Object} context.event[].data - actual data object to process - * @param {Object|undefined} context.tracer - tracer object - * @param {function(string):void} context.tracer.write - write data to tracer - * - * @returns {Promise} Promise resolved with the consumer-specific formatting applied, - * or rejected if no events are provided - */ -module.exports = function (context) { - const logger = context.logger; - const event = context.event; - const tracer = context.tracer; - - return new Promise((resolve, reject) => { - if (!Array.isArray(event)) { - const msg = 'No event data to process'; - logger.error(msg); - reject(new Error(msg)); - } - - // Create a custom metric registry each time, as the default registry will persist across API calls - const registry = new promClient.Registry(); - - // Collect metrics from event - const collectedMetrics = collectMetrics(event); - const prioritizedMetrics = prioritizeCollectedMetrics(collectedMetrics); - - const orderedPriorityKeys = Object.keys(prioritizedMetrics).sort((a, b) => a - b); - - orderedPriorityKeys.forEach((priorityKey) => { - prioritizedMetrics[priorityKey].forEach((metricObject) => { - let metricName = `f5_${metricObject.metricName}`; - // Check if already registered - if (typeof registry.getSingleMetric(metricName) !== 'undefined') { - metricName = `f5_${toPrometheusMetricFormat(metricObject.originalMetricName, true).metricName}`; - } - try { - const gauge = new promClient.Gauge({ - name: metricName, - help: metricObject.originalMetricName, - registers: [registry], - labelNames: metricObject.labelNames - }); - metricObject.labels.forEach((labelObj) => { - gauge.set(labelObj.labels, labelObj.value); - }); - } catch (err) { - logger.error(`Unable to register metric for: ${metricObject.originalMetricName}. ${err.message || err}`); - } - }); - }); - - // Return metrics from the registry - const output = registry.metrics(); - logger.verbose('success'); - if (tracer) { - tracer.write(output); - } - resolve({ - data: output, - contentType: promClient.contentType - }); - }); -}; - /** * Traverse the event data, collecting metric data, and any associated metadata. * If the SystemPoller is collecting custom data, function will fallback to metric-name formatting. * - * @param {Array.} event - Array of Data Objects collected from the consumer's System Pollers + * @param {DataCtx[]} event - data collected from the consumer's System Pollers * - * @returns {Object} Returns an Object containing metrics, metric values and any associated metadata + * @returns {object} object containing metrics, metric values and any associated metadata */ -function collectMetrics(event) { - const filteredEvents = event - .filter((d) => (typeof d !== 'undefined' && Object.keys(d).indexOf('data') !== -1)); - +function collectMetrics(events) { const metrics = []; - filteredEvents.forEach((eventObj) => { + events.forEach((eventObj) => { // If this is custom data (isCustom=true), do NOT use label-formatting - TS doesn't know the metric structure if (!eventObj.isCustom) { // Hoist and get 'dataToLabel' early in processing to make the getMetrics() processing simpler @@ -162,9 +89,9 @@ function collectMetrics(event) { * Prioritizes metrics by the number of transformations that were required to convert a metric name * into a valid Prometheus metric name. * - * @param {Object} collectedMetrics - Metrics collection + * @param {object} collectedMetrics - metrics collection * - * @returns {Object} Returns a prioritized object with required data for Prometheus formatted metrics. + * @returns {object} prioritized object with required data for Prometheus formatted metrics. * Example object: * { * 0: [ @@ -207,11 +134,11 @@ function prioritizeCollectedMetrics(collectedMetrics) { * Generates a Prometheus formatted metric name from a provided string. * Optionally replaces invalid metric name characters with hex code values * - * @param {String} name - Original metric name - * @param {Boolean} useHexFormatting - Whether to replace invalid characters with hex codes (default=false) + * @param {string} name - original metric name + * @param {boolean} useHexFormatting - whether to replace invalid characters with hex codes (default=false) * - * @returns {Object} Returns an object with the Prometheus formatted metric name, and the number of character blocks - * that were replaced + * @returns {object} object with the Prometheus formatted metric name, and the number of character blocks + * that were replaced */ function toPrometheusMetricFormat(name, useHexFormatting) { const prometheusNameRegex = /[^a-zA-Z0-9_:]+/g; @@ -240,8 +167,8 @@ function toPrometheusMetricFormat(name, useHexFormatting) { * Note: Function 'spoils' the provided 'inputData' * Note: Currently only 'hoists' data from level=root-1 to level=root. * - * @param {Object} inputData - Contains the System Poller data - * @param {Object} subKeys - Contains the sub-key and sub-key hierarchy to hoist + * @param {object} inputData - contains the System Poller data + * @param {object} subKeys - contains the sub-key and sub-key hierarchy to hoist */ function hoistSubKeys(inputData, subKeys) { Object.keys(subKeys).forEach((tKey) => { @@ -259,10 +186,10 @@ function hoistSubKeys(inputData, subKeys) { * Note: Currently only removes top-level keys from 'inputData' * Note: Function 'spoils' the provided 'inputData' * - * @param {Object} inputData - Data to transfer the keys and key data from - * @param {Array} keys - The keys to remove from inputData + * @param {object} inputData - Data to transfer the keys and key data from + * @param {string[]} keys - The keys to remove from inputData * - * @returns {Object} Object containing the keys and key-values + * @returns {object} Object containing the keys and key-values */ function getDataFromKeys(inputData, keys) { const resp = {}; @@ -277,9 +204,9 @@ function getDataFromKeys(inputData, keys) { * Pushes the given metric value into our collection of metrics. * Note: 'metrics' parameter is an "out" parameter - metrics are pushed into this object * - * @param {Array|String} pathOrKey - Either the 'path' to a metric as an Array, or the metricName as a String - * @param {Integer} metricValue - Numeric value to store as a metric point - * @param {Object} metrics - Container of all metrics + * @param {string | string[]} pathOrKey - either the 'path' to a metric as an Array, or the metricName as a String + * @param {integer} metricValue - numeric value to store as a metric point + * @param {object} metrics - container of all metrics * * "Metrics" data structure: * { @@ -334,7 +261,7 @@ function storeMetric(pathOrKey, metricValue, metrics) { * Converts an array into a key-value pair Object, and converts the Object key to follow Prometheus name * formatting requirements. * - * @param {Array} pathItems - An array representing the 'path' to a metric + * @param {Array} pathItems - an array representing the 'path' to a metric * * The input array may contain multiple key-value pairs, representing a deeply-nested Metric * Example: @@ -363,13 +290,13 @@ function getMetricLabels(pathItems) { /** * Gets the metrics (any numeric value) (and the path to those metrics in the JSON structure) from the data. * - * @param {*} data - [Object, string, number, array] to parse for metrics - * @param {Object} [options] - Optional values - * @param {String|Array} [options.pathOrKey] - The path to the metric, as a string or array. Default='' - * @param {Object} [options.metrics] - 'Container' of any existing metrics. Default={} - * @param {Boolean} [options.pathAsArray] - Whether to store metric path as an array. Default=false + * @param {*} data - [object, string, number, array] to parse for metrics + * @param {object} [options] - optional values + * @param {string|Array} [options.pathOrKey] - the path to the metric, as a string or array. Default='' + * @param {object} [options.metrics] - 'Container' of any existing metrics. Default={} + * @param {boolean} [options.pathAsArray] - whether to store metric path as an array. Default=false * - * @returns {Object} Object containing the metrics, and associated metadata + * @returns {object} object containing the metrics, and associated metadata */ function getMetrics(data, options) { const metrics = options.metrics || {}; @@ -416,3 +343,9 @@ function getMetrics(data, options) { } return metrics; } + +module.exports = { + collectMetrics, + prioritizeCollectedMetrics, + toPrometheusMetricFormat +}; diff --git a/src/lib/consumers/README.md b/src/lib/consumers/README.md index c31d2ee6..89ebf1b2 100644 --- a/src/lib/consumers/README.md +++ b/src/lib/consumers/README.md @@ -81,3 +81,35 @@ Please be aware that this solution should anticipate processing a moderately hig Value conversion rules: - *boolean* -> **Number**(*boolean*) - **true** === 1 and **false** === 0 + + +### Example v2.0 + +For all details see [API](./api.js) definition file. + +```javascript +'use strict'; + +const API = require('../api.js'); + +class MyConsumerModule extends API.ConsumerModule { + /* Module Implementation */ +} + +/** + * Load Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface | Promise} module instance + */ +module.exports = { + load({ name, path }) { + /* do some basic initialization if needed */ + /* ... */ + return new MyConsumerModule(); + } +} +``` diff --git a/src/lib/consumers/Splunk/dataMapping.js b/src/lib/consumers/Splunk/dataMapping.js index 75e3e287..8019bff2 100644 --- a/src/lib/consumers/Splunk/dataMapping.js +++ b/src/lib/consumers/Splunk/dataMapping.js @@ -22,9 +22,7 @@ const IPV6_REGEXP = /([A-Fa-f0-9]{4})([A-Fa-f0-9]{4})([A-Fa-f0-9]{4})([A-Fa-f0-9 const IPV6_V4_PREFIX_REGEXP = /::ffff:/ig; // Canonical format -function defaultFormat(globalCtx) { - const data = globalCtx.event.data; - +function defaultFormat(data) { // certain events may not have system object let time = Date.parse(new Date()); let host = 'null'; @@ -136,7 +134,7 @@ function getTemplate(sourceName, data, cache) { } function getData(request, key) { - let data = request.globalCtx.event.data; + let data = request.dataCtx.data; const splittedKey = key.split('.'); for (let i = 0; i < splittedKey.length; i += 1) { @@ -163,7 +161,7 @@ function formatHexIP(originData) { } function overall(request) { - const data = request.globalCtx.event.data; + const data = request.dataCtx.data; const template = getTemplate('bigip.stats.summary', data, request.cache); Object.assign(template.event, { files_sent: request.results.numberOfRequests, @@ -173,7 +171,7 @@ function overall(request) { } function ihealth(request) { - const data = request.globalCtx.event.data; + const data = request.dataCtx.data; const diagnostics = getData(request, 'diagnostics'); const system = getData(request, 'system'); const template = getTemplate('bigip.ihealth.diagnostics', data, request.cache); @@ -207,7 +205,7 @@ function ihealth(request) { const stats = [ function (request) { const data = getData(request, 'system'); - const template = getTemplate('bigip.tmsh.system_status', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.system_status', request.dataCtx.data, request.cache); Object.assign(template.event, { iapp_version: data.iappVersion, version: data.version, @@ -238,7 +236,7 @@ const stats = [ const networkInterfaces = getData(request, 'system.networkInterfaces'); if (networkInterfaces === undefined) return undefined; - const template = getTemplate('bigip.tmsh.interface_status', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.interface_status', request.dataCtx.data, request.cache); return Object.keys(networkInterfaces).map((key) => { const newData = Object.assign({}, template); newData.event = Object.assign({}, template.event); @@ -252,7 +250,7 @@ const stats = [ const diskStorage = getData(request, 'system.diskStorage'); if (diskStorage === undefined) return undefined; - const template = getTemplate('bigip.tmsh.disk_usage', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.disk_usage', request.dataCtx.data, request.cache); return Object.keys(diskStorage).map((key) => { const newData = Object.assign({}, template); newData.event = Object.assign({}, diskStorage[key]); @@ -266,7 +264,7 @@ const stats = [ const diskLatency = getData(request, 'system.diskLatency'); if (diskLatency === undefined) return undefined; - const template = getTemplate('bigip.tmsh.disk_latency', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.disk_latency', request.dataCtx.data, request.cache); return Object.keys(diskLatency).map((key) => { const newData = Object.assign({}, template); newData.event = Object.assign({}, diskLatency[key]); @@ -280,7 +278,7 @@ const stats = [ const sslCerts = getData(request, 'sslCerts'); if (sslCerts === undefined) return undefined; - const template = getTemplate('bigip.objectmodel.cert', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.objectmodel.cert', request.dataCtx.data, request.cache); return Object.keys(sslCerts).map((key) => { const newData = Object.assign({}, template); newData.event = Object.assign({}, template.event); @@ -295,7 +293,7 @@ const stats = [ const vsStats = getData(request, 'virtualServers'); if (vsStats === undefined) return undefined; - const template = getTemplate('bigip.tmsh.virtual_status', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.virtual_status', request.dataCtx.data, request.cache); return Object.keys(vsStats).map((key) => { const vsStat = vsStats[key]; const newData = Object.assign({}, template); @@ -315,7 +313,7 @@ const stats = [ const vsStats = getData(request, 'virtualServers'); if (vsStats === undefined) return undefined; - const template = getTemplate('bigip.objectmodel.virtual', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.objectmodel.virtual', request.dataCtx.data, request.cache); return Object.keys(vsStats).map((key) => { const vsStat = vsStats[key]; const newData = Object.assign({}, template); @@ -337,7 +335,7 @@ const stats = [ const vsStats = getData(request, 'virtualServers'); if (vsStats === undefined) return undefined; - const template = getTemplate('bigip.objectmodel.virtual.profiles', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.objectmodel.virtual.profiles', request.dataCtx.data, request.cache); const ret = []; Object.keys(vsStats).forEach((vsKey) => { const vsStat = vsStats[vsKey]; @@ -364,7 +362,7 @@ const stats = [ const vsStats = getData(request, 'virtualServers'); if (vsStats === undefined) return undefined; - const template = getTemplate('bigip.objectmodel.virtual.pools', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.objectmodel.virtual.pools', request.dataCtx.data, request.cache); const ret = []; Object.keys(vsStats).forEach((key) => { const vsStat = vsStats[key]; @@ -388,7 +386,7 @@ const stats = [ const poolStats = getData(request, 'pools'); if (poolStats === undefined) return undefined; - const template = getTemplate('bigip.tmsh.pool_member_status', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmsh.pool_member_status', request.dataCtx.data, request.cache); const output = []; Object.keys(poolStats).forEach((poolName) => { @@ -417,7 +415,7 @@ const stats = [ if (tmstats === undefined) return undefined; const hexIpProps = ['addr', 'source', 'destination']; - const template = getTemplate('bigip.tmstats', request.globalCtx.event.data, request.cache); + const template = getTemplate('bigip.tmstats', request.dataCtx.data, request.cache); const output = []; Object.keys(tmstats).forEach((key) => { diff --git a/src/lib/consumers/Splunk/index.js b/src/lib/consumers/Splunk/index.js index 379a73a9..1de5be91 100644 --- a/src/lib/consumers/Splunk/index.js +++ b/src/lib/consumers/Splunk/index.js @@ -16,15 +16,21 @@ 'use strict'; -const zlib = require('zlib'); +/* eslint-disable no-cond-assign, no-unused-expressions */ +const pako = require('pako'); + +const API = require('../api'); +const { CircularLinkedList } = require('../../utils/structures'); const dataMapping = require('./dataMapping'); const EVENT_TYPES = require('../../constants').EVENT_TYPES; +const httpUtil = require('../../utils/http'); const memConverter = require('./multiMetricEventConverter'); -const httpUtil = require('../shared/httpUtil'); -const promiseUtil = require('../../utils/promise'); +const TaskQueue = require('../../utils/taskQueue'); +const util = require('../../utils/misc'); -const MAX_CHUNK_SIZE = 99000; +const MAX_PAYLOAD_SIZE = 99000; +const MAX_WORKERS = 30; const HEC_EVENTS_URI = '/services/collector/event'; const HEC_METRICS_URI = '/services/collector'; const DATA_FORMATS = { @@ -34,17 +40,130 @@ const DATA_FORMATS = { }; /** - * See {@link ../README.md#context} for documentation + * @module consumers/default * - * @returns {Promise} resolved once data sent to destination (never rejects) + * @typedef {import('../api').ConsumerCallback} ConsumerCallback + * @typedef {import('../api').ConsumerConfig} ConsumerConfig + * @typedef {import('../api').ConsumerInterface} ConsumerInterface + * @typedef {import('../api').ConsumerModuleInterface} ConsumerModuleInterface + * @typedef {import('../../dataPipeline').DataEventCtxV2} DataCtx */ -module.exports = function (context) { - return transformData(context) - .then((data) => forwardData(data, context)) - .catch((err) => { - context.logger.exception('Splunk data processing error', err); + +/** + * Telemetry Streaming Splunk Push Consumer + * + * @implements {ConsumerInterface} + */ +class SplunkConsumer extends API.Consumer { + /** @inheritdoc */ + get allowsPull() { + return false; + } + + /** @inheritdoc */ + get allowsPush() { + return true; + } + + /** @inheritdoc */ + onData(dataCtx) { + this.ingressDataQueue.push(dataCtx); + this.processNewData(true); + } + + /** @inheritdoc */ + async onLoad(config) { + await super.onLoad(config); + + let dataProcessor = legacyDataProcessor; + if (this.originConfig.format === DATA_FORMATS.METRICS) { + dataProcessor = multiMetricDataProcessor; + } else if (this.originConfig.format !== DATA_FORMATS.LEGACY) { + dataProcessor = defaultDataProcessor; + } + + let allowSelfSignedCert = this.originConfig.allowSelfSignedCert; + if (!util.isObjectEmpty(this.originConfig.proxy) + && typeof this.originConfig.proxy.allowSelfSignedCert !== 'undefined') { + allowSelfSignedCert = this.originConfig.proxy.allowSelfSignedCert; + } + + const headers = { Authorization: `Splunk ${this.originConfig.passphrase}` }; + const httpAgent = httpUtil.getAgent( + Object.assign({ connection: { protocol: this.originConfig.protocol } }, this.originConfig) + ).agent; + const useGzip = this.originConfig.compressionType === 'gzip'; + if (useGzip) { + headers['Content-Encoding'] = 'gzip'; + } + + Object.defineProperties(this, { + egressQueue: { + value: new TaskQueue(async (task, done) => { + try { + await this.dataForwarder(task); + } catch (err) { + this.logger.exception('Uncaught error on attempt to send data', err); + } finally { + done(); + } + }, { + concurency: getMaxWorkers(this.originConfig), + logger: this.logger.getChild('EgressQueue'), + maxSize: 100000, + name: 'EgressQueue', + usePriority: false + }) + }, + dataForwarder: { + value: forwardData.bind(this, useGzip) + }, + dataProcessor: { + value: dataProcessor.bind(this) + }, + ingressDataQueue: { + value: new CircularLinkedList() + }, + maxEgressWorkers: { + value: getMaxWorkers(this.originConfig) + }, + processNewData: { + value: processNewData.bind(this) + }, + requestOptions: { + get() { + return { + agent: httpAgent, + allowSelfSignedCert, + headers: Object.assign({}, headers), + hosts: [this.originConfig.host], + json: false, + logger: this.logger, + method: 'POST', + port: this.originConfig.port, + protocol: this.originConfig.protocol, + proxy: this.originConfig.proxy, + uri: this.originConfig.format === DATA_FORMATS.METRICS + ? HEC_METRICS_URI + : HEC_EVENTS_URI + }; + } + } }); -}; + } +} + +/** + * Telemetry Streaming Splunk Consumer Module + * + * @implements {ConsumerModuleInterface} + */ +class SplunkConsumerModule extends API.ConsumerModule { + /** @inheritdoc */ + async createConsumer() { + return new SplunkConsumer(); + } +} /** * Add transformed data @@ -54,89 +173,67 @@ module.exports = function (context) { */ function appendData(ctx, newData) { const results = ctx.results; - const newDataStr = JSON.stringify(newData, ctx.results.jsonReplacer); + newData = JSON.stringify(newData); - results.currentChunkLength += newDataStr.length; - results.dataLength += newDataStr.length; + results.currentChunkLength += newData.length; + results.dataLength += newData.length; - if (results.currentChunkLength >= MAX_CHUNK_SIZE) { + if (results.currentChunkLength >= MAX_PAYLOAD_SIZE) { results.currentChunkLength = 0; results.numberOfRequests += 1; } - results.translatedData.push(newDataStr); + results.translatedData.push(newData); } /** * Transform data using provided callback * - * @param {Function(object):object} cb - callback function to transform - * @param {Object} ctx - context object + * @this {SplunkConsumer} * - * @returns {Promise} resolved once data transformed + * @param {function(object):object} cb - callback function to transform data + * @param {object} ctx - context object */ function safeDataTransform(cb, ctx) { - return Promise.resolve() - .then(() => cb(ctx)) - .then((data) => { - if (data) { - if (Array.isArray(data)) { - data.forEach((part) => appendData(ctx, part)); - } else { - appendData(ctx, data); - } - } - }).catch((err) => { - ctx.globalCtx.logger.exception('Splunk.safeDataTransform error', err); - }); + let data; + try { + data = cb(ctx); + } catch (err) { + this.logger.exception('Splunk.safeDataTransform error', err); + } + if (data) { + if (Array.isArray(data)) { + data.forEach((part) => appendData(ctx, part)); + } else { + appendData(ctx, data); + } + } } /** - * Convert data to default format + * Process incoming data and convert it to default format * - * @param {Object} ctx - context object + * @this {SplunkConsumer} * - * @returns {Promise>} resolved once default format applied to data + * @param {DataCtx} dataCtx - data context + * + * @returns {string[]} transformed data */ -function defaultDataFormat(ctx) { - return Promise.resolve([JSON.stringify(dataMapping.defaultFormat(ctx))]); +function defaultDataProcessor(dataCtx) { + return [JSON.stringify(dataMapping.defaultFormat(dataCtx.data))]; } /** - * Convert data to multi metric format + * Process incoming data and convert it to legacy format (Analytics iApp) * - * @param {Object} ctx - context object + * @this {SplunkConsumer} * - * @returns {Promise} resolved with transformed data - */ -function multiMetricDataFormat(ctx) { - return Promise.resolve() - .then(() => { - const events = []; - memConverter(ctx.event.data, (event) => events.push(JSON.stringify(event))); - return events; - }); -} - -/** - * Transform incoming data - * - * @param {Object} globalCtx - global context + * @param {DataCtx} dataCtx - data context * - * @returns {Promise>} resolved with transformed data + * @returns {string[]} transformed data */ -function transformData(globalCtx) { - if (globalCtx.config.format === DATA_FORMATS.METRICS) { - if (globalCtx.event.type === EVENT_TYPES.SYSTEM_POLLER && !globalCtx.event.isCustom) { - return multiMetricDataFormat(globalCtx); - } - return Promise.resolve([]); - } - if (globalCtx.config.format !== DATA_FORMATS.LEGACY) { - return defaultDataFormat(globalCtx); - } - +function legacyDataProcessor(dataCtx) { const requestCtx = { - globalCtx, + dataCtx, results: { dataLength: 0, currentChunkLength: 0, @@ -147,163 +244,121 @@ function transformData(globalCtx) { dataTimestamp: (new Date()).getTime() } }; - if (globalCtx.config.dumpUndefinedValues) { - requestCtx.results.jsonReplacer = function (key, value) { - return value === undefined ? 'UNDEFINED' : value; - }; - } - let p = null; - if (globalCtx.event.type === EVENT_TYPES.SYSTEM_POLLER && !globalCtx.event.isCustom) { - requestCtx.cache.dataTimestamp = Date.parse(globalCtx.event.data.system.systemTimestamp); - p = promiseUtil.allSettled(dataMapping.stats.map((func) => safeDataTransform(func, requestCtx))) - .then((results) => { - promiseUtil.getValues(results); // throws error if found it - return safeDataTransform(dataMapping.overall, requestCtx); - }); - } else if (globalCtx.event.type === EVENT_TYPES.IHEALTH_POLLER) { - p = safeDataTransform(dataMapping.ihealth, requestCtx); + if (dataCtx.type === EVENT_TYPES.SYSTEM_POLLER && !dataCtx.isCustom) { + requestCtx.cache.dataTimestamp = Date.parse(dataCtx.data.system.systemTimestamp); + dataMapping.stats.map((func) => safeDataTransform.call(this, func, requestCtx)); + safeDataTransform.call(this, dataMapping.overall, requestCtx); + } else if (dataCtx.type === EVENT_TYPES.IHEALTH_POLLER) { + safeDataTransform.call(this, dataMapping.ihealth, requestCtx); } - - if (!p) { - return Promise.resolve([]); - } - return p.then(() => requestCtx.results.translatedData); + return requestCtx.results.translatedData; } /** - * Create default options for request + * Process incoming data and convert it to multi metric format + * + * @this {SplunkConsumer} * - * @param {Object} consumer - consumer's config object + * @param {DataCtx} dataCtx - data context * - * @returns {Object} options for requestUtil + * @returns {string[]} transformed data */ -function getRequestOptions(consumer) { - const requestOpts = { - allowSelfSignedCert: consumer.allowSelfSignedCert, - gzip: consumer.compressionType === 'gzip', - headers: { - Authorization: `Splunk ${consumer.passphrase}` - }, - hosts: [consumer.host], - json: false, - method: 'POST', - port: consumer.port, - protocol: consumer.protocol ? consumer.protocol : 'https', - proxy: consumer.proxy, - uri: consumer.format === DATA_FORMATS.METRICS ? HEC_METRICS_URI : HEC_EVENTS_URI - }; - // easier for debug to turn it off - if (requestOpts.gzip) { - Object.assign(requestOpts.headers, { - 'Accept-Encoding': 'gzip', - 'Content-Encoding': 'gzip' - }); +function multiMetricDataProcessor(dataCtx) { + if (dataCtx.type === EVENT_TYPES.SYSTEM_POLLER && !dataCtx.isCustom) { + const events = []; + memConverter(dataCtx.data, (event) => events.push(JSON.stringify(event))); + return events; } - if (requestOpts.proxy && typeof requestOpts.proxy.allowSelfSignedCert !== 'undefined') { - requestOpts.allowSelfSignedCert = requestOpts.proxy.allowSelfSignedCert; - } - return requestOpts; + return []; } /** - * Send data to consumer + * Forward data to consumer * - * @param {string[]} dataChunk - list of strings to send - * @param {Object} context - context - * @param {Object} context.request - request object - * @param {Object} context.consumer - consumer object + * @this {SplunkConsumer} * - * @returns {Promise} resolved with complete response + * @param {string} dataChunk - data to send */ -function sendDataChunk(dataChunk, context) { - const logger = context.globalCtx.logger; - - return new Promise((resolve, reject) => { - const data = dataChunk.join(''); - - if (context.requestOpts.gzip) { - zlib.gzip(data, (err, buffer) => { - if (!err) { - resolve(buffer); - } else { - err = `sendDataChunk::zlib.gzip error: ${err}`; - logger.error(err); - reject(err); +function forwardData(useGzip, dataChunk) { + this.writeTraceData({ + dataChunk, + requestOptions: this.requestOptions + }); + + const requestOpts = this.requestOptions; + if (useGzip) { + dataChunk = pako.gzip(dataChunk); + } + + this.logger.verbose(`sending data - ${dataChunk.length} bytes`); + requestOpts.headers['Content-Length'] = dataChunk.length; + requestOpts.body = dataChunk; + + return httpUtil.sendToConsumer.call(this, requestOpts) + .catch((error) => { + this.logger.exception('Unable to send data chunk:', error); + }); +} + +function getMaxWorkers(config) { + const maxSockets = (config.customOpts || []) + .find((opt) => opt.name === 'maxSockets'); + return maxSockets ? maxSockets.value : MAX_WORKERS; +} + +function processNewData(detach) { + if (typeof this._processNewDataTimeoutID === 'undefined') { + this._processNewDataTimeoutID = setTimeout(this.processNewData, 100); + } + if (detach) { + return; + } + + this._processNewDataTimeoutID = undefined; + + let output = []; + let totalLength = 0; + + let maxPayloadsToBuild = this.maxEgressWorkers * 2 - this.egressQueue.size(); + + while (this.ingressDataQueue.length > 0 && maxPayloadsToBuild > 0) { + const dataCtx = this.ingressDataQueue.pop(); + const chunks = this.dataProcessor(dataCtx); + for (let i = 0; i < chunks.length; i += 1) { + if (totalLength < MAX_PAYLOAD_SIZE) { + if (chunks[i].length > 0) { + output.push(chunks[i]); + totalLength += chunks[i].length; } - }); - } else { - resolve(data); + } else { + this.logger.verbose(`Events in payload: ${output.length}. Total length: ${totalLength}`); + this.egressQueue.push(output.join('')); + output = []; + totalLength = 0; + maxPayloadsToBuild -= 1; + } } - }).then((data) => { - logger.verbose(`sending data - ${data.length} bytes`); - const opts = Object.assign({ body: data, logger }, context.requestOpts); - opts.headers = Object.assign(opts.headers, { - 'Content-Length': data.length - }); - return httpUtil.sendToConsumer(opts); - }); + } + if (totalLength > 0) { + this.logger.verbose(`Events in payload: ${output.length}`); + this.egressQueue.push(output.join('')); + } + if (this.ingressDataQueue.length) { + this.processNewData(true); + } } /** - * Forward data to consumer + * Load Telemetry Streaming Splunk Consumer module + * + * Note: called once only if not in memory yet * - * @param {Array} dataToSend - list of strings to send - * @param {Object} globalCtx - global context object + * @param {API.ModuleConfig} moduleConfig - module's config * - * @returns {Promise} resolve once data sent to destination + * @return {API.ConsumerModuleInterface} module instance */ -function forwardData(dataToSend, globalCtx) { - if (!dataToSend || dataToSend.length === 0) { - globalCtx.logger.verbose('No data to forward to Splunk'); - return Promise.resolve(); +module.exports = { + async load() { + return new SplunkConsumerModule(); } - const context = { - globalCtx, - requestOpts: getRequestOptions(globalCtx.config), - consumer: globalCtx.config - }; - if (globalCtx.tracer) { - // redact passphrase in consumer config - const tracedConsumerCtx = JSON.parse(JSON.stringify(context.consumer)); - tracedConsumerCtx.passphrase = '*****'; - // redact passphrase in proxy config - if (tracedConsumerCtx.proxy && tracedConsumerCtx.proxy.passphrase) { - tracedConsumerCtx.proxy.passphrase = '*****'; - } - // redact passphrase in request options - const tracedRequestOpts = JSON.parse(JSON.stringify(context.requestOpts)); - tracedRequestOpts.headers.Authorization = '*****'; - // redact passphrase in proxy config - if (tracedRequestOpts.proxy && tracedRequestOpts.proxy.passphrase) { - tracedRequestOpts.proxy.passphrase = '*****'; - } - globalCtx.tracer.write({ - dataToSend, - consumer: tracedConsumerCtx, - requestOpts: tracedRequestOpts - }); - } - - return new Promise((resolve) => { - (function sendNextChunk(startIdx) { - if (startIdx >= dataToSend.length) { - return resolve(); - } - return Promise.resolve() - .then(() => { - const dataChunks = []; - let chunksSize = 0; - - while (chunksSize < MAX_CHUNK_SIZE && startIdx < dataToSend.length) { - chunksSize += dataToSend[startIdx].length; - dataChunks.push(dataToSend[startIdx]); - startIdx += 1; - } - return sendDataChunk(dataChunks, context); - }).catch((error) => { - globalCtx.logger.exception('Unable to send data chunk', error); - }) - .then(() => sendNextChunk(startIdx)); - }(0)); // calling the immediate function here and pass 0 as startIdx - }); -} +}; diff --git a/src/lib/consumers/Splunk/multiMetricEventConverter.js b/src/lib/consumers/Splunk/multiMetricEventConverter.js index 75d13564..63091f17 100644 --- a/src/lib/consumers/Splunk/multiMetricEventConverter.js +++ b/src/lib/consumers/Splunk/multiMetricEventConverter.js @@ -382,6 +382,7 @@ module.exports = function (data, cb) { * @callback CastCb * @param {String} key - key * @param {Any} value - value to cast to metric + * * @returns {Number} metric value */ /** @@ -396,6 +397,7 @@ module.exports = function (data, cb) { * @callback DeleteCb * @param {String} key - key to delete * @param {Any} value - value + * * @returns {Boolean} true if key needs to be deleted */ /** @@ -412,6 +414,7 @@ module.exports = function (data, cb) { * @callback PrefixCb * @param {String} key - key to prepend prefix to * @param {Any} value - value + * * @returns {String} prefix to prepend to a key */ /** @@ -420,6 +423,7 @@ module.exports = function (data, cb) { * @callback RenameCb * @param {String} key - key to rename * @param {Any} value - value + * * @returns {String} renamed key */ /** @@ -428,6 +432,7 @@ module.exports = function (data, cb) { * @callback SkipCb * @param {String} key - key to skip * @param {Any} value - value + * * @returns {Boolean} true if key needs to be skipped */ /** diff --git a/src/lib/consumers/api.js b/src/lib/consumers/api.js new file mode 100644 index 00000000..f5b8d50c --- /dev/null +++ b/src/lib/consumers/api.js @@ -0,0 +1,437 @@ +/* + * Copyright 2024. F5 Networks, Inc. See End User License Agreement ("EULA") for + * license terms. Notwithstanding anything to the contrary in the EULA, Licensee + * may copy and modify this software product for its internal business purposes. + * Further, Licensee may upload, publish and distribute the modified version of + * the software product on devcentral.f5.com. + */ + +'use strict'; + +/** + * @module consumers/api + * + * @typedef {import('../utils/config').ConsumerComponent} ConsumerComponent + * @typedef {import('../dataPipeline').DataEventCtxV2} DataCtx + */ + +const NotImplementedError = require('../errors').NotImplementedError; + +// TODO: update type imports + +const PULL_EVENT = 1; +const PUSH_EVENT = 2; + +/** + * Telemetry Streaming Consumer Interface + * + * NOTE: + * - when both `allowsPull` and `allowsPush` return `true` then Telemetry Streaming + * assumes that accepts data as regular `push` consumer and returns cached data on `pull` event + * + * @interface + * + * @property {string} id - unique ID + * @property {Logger} logger - instance of Logger + * @property {ConsumerComponent} originConfig - COPY of original config component + * @property {string} name - config name + * @property {string} serviceName - service name + * @property {string} traceID - trace name from TS declaration + * @property {Tracer} tracer - Tracer instance + * @property {Tracer} writeTraceData - writes data to tracer if configured + */ +class ConsumerInterface { + /** + * @public + * @returns {boolean} true if consumer supports 'PULL' method for data + */ + get allowsPull() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {boolean} true if consumer supports 'PUSH' method for data + */ + get allowsPush() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} unique ID + */ + get id() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {Logger} instance + */ + get logger() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {ConsumerComponent} COPY of original config component + */ + get originConfig() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} config name + */ + get name() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} service name + */ + get serviceName() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} trace name from config + */ + get traceID() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {function} writes data to tracer if configured + */ + get writeTraceData() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming has data ready for processing. + * + * Note: + * - instance is responsible to stop data processing once `.onUnload()` invoked + * - instance is responsible to catch all errors + * - for pull consumers 'dataCtx' is array + * + * @public + * @param {DataCtx | DataCtx[]} dataCtx - data context + * @param {number} emask - event mask + * @param {null | ConsumerCallback} [callback] - callback to call once data sent or processed + */ + onData() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming need to pass configuration to Consumer instance. + * Method might be useful to establish connection or etc. + * + * @public + * @param {ConsumerConfig} config - original configuration component + * + * @returns {Promise} resolved once instance loaded + */ + onLoad() { + throw new NotImplementedError(); + } + + /** + * Method called before Telemetry Streaming need to stop and remove Consumer instance. + * Method might be useful to close and cleanup resources and etc. + * + * Note: no more data will be passed to 'onData' once this invoked. + * + * @public + * @returns {Promise} resolved once instance unloaded + */ + onUnload() { + throw new NotImplementedError(); + } +} + +/** + * Telemetry Streaming Consumer Module Interface + * + * @interface + * + * @property {Logger} logger - instance of Logger + * @property {string} name - module name + * @property {string} path - module path + */ +class ConsumerModuleInterface { + /** + * @public + * @returns {Logger} instance + */ + get logger() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} module name + */ + get name() { + throw new NotImplementedError(); + } + + /** + * @public + * @returns {string} module path + */ + get path() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming needs to create new Consumer instance according to a declaration. + * Consumer instance should implement `ConsumerInterface` protocol. + * + * @public + * @param {ConsumerConfig} config - original configuration component + * + * @returns {Promise} resolved with instance of ConsumerInterface + */ + createConsumer() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming needs to delete existing Consumer instance according to a declaration. + * + * @public + * @param {ConsumerInterface} instance + * @param {Error} [error] - error caught on attempt to call ConsumerInterface.prototype.onUnload + * + * @returns {Promise} resolve once ConsumerInterface instance deleted + */ + deleteConsumer() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming needs to load Consumer module. + * Method might be useful to make call to some slow API to cache result and etc. + * + * @public + * @param {ModuleConfig} config - module config + * + * @returns {Promise} resolved once module loaded + */ + onLoad() { + throw new NotImplementedError(); + } + + /** + * Method called when Telemetry Streaming needs to unload Consumer module because no Consumers + * of such type left in a declaration. Method might be useful to cleanup cached results. + * Next time when Telemetry Streaming found Consumer(s) of such type in a declaration + * it will call `ConsumerModuleInterface.prototype.onLoad()` + * + * @public + * @returns {Promise} resolved once module unloaded + */ + onUnload() { + throw new NotImplementedError(); + } +} + +/** + * Telemetry Streaming Consumer + * + * @implements {ConsumerInterface} + */ +class Consumer extends ConsumerInterface { + /** + * Method called when Telemetry Streaming has data ready for processing. + * + * Note: + * - instance is responsible to stop data processing once `.onUnload()` invoked + * - instance is responsible to catch all errors + * - for pull consumers 'dataCtx' is array + * + * @public + * @param {DataCtx | DataCtx[]} dataCtx - data context + * @param {number} emask - event mask + * @param {null | ConsumerCallback} [callback] - callback to call once data sent or processed + */ + onData() {} + + /** + * Method called when Telemetry Streaming need to pass configuration to Consumer instance. + * Method might be useful to establish connection or etc. + * + * @public + * @param {ConsumerConfig} config - copy of original configuration component + * + * @returns {Promise} resolved once instance loaded/initialized + */ + onLoad(config) { + return new Promise((resolve) => { + /** + * all properties below are read-only + */ + Object.defineProperties(this, { + id: { + value: config.id + }, + logger: { + value: config.logger + }, + name: { + value: config.name + }, + originConfig: { + value: config.config + }, + serviceName: { + value: config.type + }, + traceID: { + value: config.fullName + }, + writeTraceData: { + value: config.tracer + } + }); + + this.logger.debug('Basic initialization - done!'); + resolve(); + }); + } + + /** + * Method called before Telemetry Streaming need to stop and remove Consumer instance. + * Method might be useful to close and cleanup resources and etc. + * + * Note: + * - no more data will be passed to 'onData' once this invoked. + * - run cleanup actions when all pending data was processed only + * otherwise it may lead to some errors e.g. connection closed earlier + * then data was sent + * + * @public + * @returns {Promise} resolved once instance unloaded/de-initialized + */ + onUnload() { + return new Promise((resolve) => { + this.logger.debug('Unloading instance'); + resolve(); + }); + } +} + +/** + * Telemetry Streaming Consumer Module + * + * @implements {ConsumerModuleInterface} + */ +class ConsumerModule extends ConsumerModuleInterface { + /** + * Method called when Telemetry Streaming needs to create new Consumer instance according to a declaration. + * Consumer instance should implement `ConsumerInterface` protocol. + * + * @public + * @param {ConsumerConfig} config - original configuration component + * + * @returns {Promise} resolved with instance of ConsumerInterface + */ + createConsumer() { + return Promise.resolve(new Consumer()); + } + + /** + * Method called when Telemetry Streaming needs to delete existing Consumer instance according to a declaration. + * + * @public + * @param {ConsumerInterface} instance + * @param {Error} [error] - error caught on attempt to call ConsumerInterface.prototype.onUnload + * + * @returns {Promise} resolve once ConsumerInterface instance deleted + */ + deleteConsumer() { + return Promise.resolve(); + } + + /** + * Method called when Telemetry Streaming needs to load Consumer module. + * Method might be useful to make call to some slow API to cache result and etc. + * + * @public + * @param {ModuleConfig} config - module config + * + * @returns {Promise} resolved once module loaded + */ + onLoad({ logger, name, path }) { + return new Promise((resolve) => { + Object.defineProperties(this, { + logger: { + value: logger + }, + name: { + value: name + }, + path: { + value: path + } + }); + + this.logger.debug('Basic initialization - done!'); + resolve(); + }); + } + + /** + * Method called when Telemetry Streaming needs to unload Consumer module because no Consumers + * of such type left in a declaration. Method might be useful to cleanup cached results. + * Next time when Telemetry Streaming found Consumer(s) of such type in a declaration + * it will call `ConsumerModuleInterface.prototype.onLoad()` + * + * @public + * @returns {Promise} resolved once module unloaded + */ + onUnload() { + return Promise.resolve(); + } +} + +module.exports = { + Consumer, + ConsumerInterface, + ConsumerModule, + ConsumerModuleInterface, + PULL_EVENT, + PUSH_EVENT +}; + +/** + * @callback ConsumerCallback + * @param {null | object} error - processing error + * @param {null | object} data - processed data (optional) + */ +/** + * @typedef ConsumerConfig + * @type {object} + * @property {ConsumerComponent} config - consumer's configuration + * @property {string} fullName - consumer's full name + * @property {string} id - consumer's id + * @property {Logger} logger - logger + * @property {string} name - consumer's name + * @property {function} tracer + * @property {string} type - consumer module type + */ +/** + * @typedef ModuleConfig + * @type {object} + * @property {Logger} logger - logger + * @property {string} name - module name + * @property {string} path - module path + */ diff --git a/src/lib/consumers/default/index.js b/src/lib/consumers/default/index.js index 75269688..04b43fbb 100644 --- a/src/lib/consumers/default/index.js +++ b/src/lib/consumers/default/index.js @@ -16,26 +16,96 @@ 'use strict'; +/* eslint-disable no-unused-expressions */ + +const API = require('../api'); + +/** + * @module consumers/default + * + * @typedef {import('../api').ConsumerCallback} ConsumerCallback + * @typedef {import('../api').ConsumerConfig} ConsumerConfig + * @typedef {import('../api').ConsumerInterface} ConsumerInterface + * @typedef {import('../api').ConsumerModuleInterface} ConsumerModuleInterface + * @typedef {import('../../dataPipeline').DataEventCtxV2} DataCtx + */ + /** - * See {@link ../README.md#context} for documentation + * Telemetry Streaming Default Pull Consumer + * + * @implements {ConsumerInterface} */ -module.exports = function (context) { - const logger = context.logger; // eslint-disable-line no-unused-vars - const event = context.event; // eslint-disable-line no-unused-vars - const config = context.config; // eslint-disable-line no-unused-vars - const tracer = context.tracer; // eslint-disable-line no-unused-vars +class DefaultPullConsumer extends API.Consumer { + /** @inheritdoc */ + get allowsPull() { + return true; + } - if (!event) { - const msg = 'No event to process'; - logger.error(msg); - return Promise.reject(new Error(msg)); + /** @inheritdoc */ + get allowsPush() { + return false; + } + + /** @inheritdoc */ + onData(dataCtxs, emask, callback) { + this.logger.verbose(`Data types '${dataCtxs.map((d) => d.type).join(', ')}' processed`); + callback(null, { + contentType: 'application/json', + data: dataCtxs.map((d) => d.data) + }); + } +} + +/** + * Telemetry Streaming Default Push Consumer + * + * @implements {ConsumerInterface} + */ +class DefaultPushConsumer extends API.Consumer { + /** @inheritdoc */ + get allowsPull() { + return false; } - logger.verbose(`Data type '${event.type}' processed`); - if (tracer) { - // pretty JSON dump - tracer.write(event.data); + /** @inheritdoc */ + get allowsPush() { + return true; + } + + /** @inheritdoc */ + onData(dataCtx, emask, callback) { + this.logger.verbose(`Data type '${dataCtx.type}' processed`); + this.writeTraceData(dataCtx.data); + callback && callback(null, dataCtx.data); + } +} + +/** + * Telemetry Streaming Default Consumer Module + * + * @implements {ConsumerModuleInterface} + */ +class DefaultConsumerModule extends API.ConsumerModule { + /** @inheritdoc */ + async createConsumer({ config }) { + if (config.class === 'Telemetry_Consumer') { + return new DefaultPushConsumer(); + } + return new DefaultPullConsumer(); + } +} + +/** + * Load Telemetry Streaming Default Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + async load() { + return new DefaultConsumerModule(); } - // nothing to do, default plugin - return Promise.resolve(); }; diff --git a/src/lib/consumers/index.js b/src/lib/consumers/index.js new file mode 100644 index 00000000..89a4bcb8 --- /dev/null +++ b/src/lib/consumers/index.js @@ -0,0 +1,534 @@ +/* + * Copyright 2024. F5 Networks, Inc. See End User License Agreement ("EULA") for + * license terms. Notwithstanding anything to the contrary in the EULA, Licensee + * may copy and modify this software product for its internal business purposes. + * Further, Licensee may upload, publish and distribute the modified version of + * the software product on devcentral.f5.com. + */ + +'use strict'; + +/* eslint-disable consistent-return, no-unused-expressions */ + +const filter = require('lodash/filter'); +const find = require('lodash/find'); +const groupBy = require('lodash/groupBy'); +const path = require('path'); + +const assert = require('../utils/assert'); +const CONFIG_CLASSES = require('../constants').CONFIG_CLASSES; +const configUtil = require('../utils/config'); +const metadataUtil = require('../utils/metadata'); +const miscUtil = require('../utils/misc'); +const moduleLoader = require('../utils/moduleLoader').ModuleLoader; +const Service = require('../utils/service'); +const tracerMgr = require('../tracerManager'); + +/** + * @module consumers + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('./api').ConsumerCallback} ConsumerCallback + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('../utils/config').ConsumerComponent} ConsumerComponent + * @typedef {import('../dataPipeline').DataEventCtxV1} DataEventCtxV1 + * @typedef {import('../dataPipeline').DataEventCtxV2} DataEventCtxV2 + * @typedef {import('../logger').Logger} Logger + * @typedef {import('../utils/tracer').Tracer} Tracer + */ + +const EE_NAMESPACE = 'consumers'; // namespace for global events +const ENTRY_FILE = 'index.js'; // plug-in's entry point + +/** + * Dummy function when 'tracer' not configured + */ +function dummyTracerWrite() {} + +/** + * Consumers Service Class + * + * @fires consumers.updated - aka 'consumers.change' as global event + * @fires config.applied - aka 'consumers.done' as global event + */ +class ConsumersService extends Service { + /** @param {string} [pluginsDir] - path to directory with plug-ins */ + constructor(pluginsDir = __dirname) { + super(); + + assert.string(pluginsDir, 'pluginsDir'); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + pluginsDir: { + value: pluginsDir + } + }); + + this._consumers = {}; + this._modules = {}; + this._supportedModules = []; + } + + /** @returns {integer} number of active consumers */ + get numberOfConsumers() { + return Object.keys(this._consumers).length; + } + + /** @returns {integer} number of active consumer plug-ins */ + get numberOfModules() { + return Object.keys(this._modules).length; + } + + /** @returns {string[]} arary of recognized plug-ins */ + get supportedModules() { + return this._supportedModules.slice(); + } + + /** @inheritdoc */ + async _onStart() { + this._consumers = {}; + this._modules = {}; + this._supportedModules = []; + + this._registerEvents(); + + await init.call(this); + } + + /** @inheritdoc */ + async _onStop() { + // stop receiving config updates + this._configListener.off(); + this._configListener = null; + + await freeConsumers.call(this); + await freeModules.call(this); + await emitConsumersUpdate.call(this); + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + + this._consumers = null; + this._modules = null; + this._supportedModules = null; + } + + /** @param {ApplicationEvents} appEvents - global event emitter */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._configListener = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'consumers.updated': 'change' }, + { 'config.applied': 'config.done' } + ]); + }; + } +} + +/** + * Free consumer instances + * + * Due `config` implementation there is no option like + * partial update - e.g. to guess what property was updated and etc. + * So, as first step all consumers should be removed first. + * Exception is only situation when namespace was updated, then + * only consumers that belong to that namespace should be removed + * + * @this ConsumersService + * + * @param {ConsumerComponent[]} consumers - configured consumers + * + * @returns {Promise} resolved once desired consumers freed + */ +function freeConsumers(consumers) { + return Promise.resolve() + .then(() => { + consumers = consumers || []; + // it might be empty if entire declaration was updated -> free all then + const toKeep = filter(consumers, (consumerCfg) => consumerCfg.skipUpdate && consumerCfg.enable); + const toRemove = filter(this._consumers, (consumerCtx) => typeof find(toKeep, ['id', consumerCtx.id]) === 'undefined'); + return Promise.all(toRemove.map((consumerCtx) => Promise.resolve() + .then(() => { + this.logger.debug(`Freeing consumer "${consumerCtx.fullName}" (${consumerCtx.type})`); + + delete this._consumers[consumerCtx.id]; + return consumerCtx.v2 && Promise.resolve() + .then(() => consumerCtx.instanceCtx.onUnload()) + .catch((err) => this.logger.exception( + `Uncaught exception on attemp to call ".onUnload()" method for consumer "${consumerCtx.fullName}" (${consumerCtx.type})`, + err + )) + .then(() => this._modules[consumerCtx.type].instanceCtx.deleteConsumer(consumerCtx.instanceCtx)) + .catch((err) => this.logger.exception( + `Uncaught exception on attemp to call ".deleteConsumer()" method for consumer "${consumerCtx.fullName}" (${consumerCtx.type})`, + err + )); + }) + .then(() => this.logger.debug(`Consumer "${consumerCtx.fullName}" (${consumerCtx.type}) freed!`)) + .catch((err) => this.logger.exception(`Uncaught exception on attemp to free consumer "${consumerCtx.fullName}" (${consumerCtx.type})`, err)))); + }) + .catch((err) => this.logger.exception('Uncaught exception on attemp to free consumers', err)); +} + +/** + * Free consumer plug-ins + * + * @this ConsumersService + * + * @returns {Promise} resolved once desired consumers plug-ins freed + */ +function freeModules() { + return Promise.resolve() + .then(() => { + const toRemove = filter(this._modules, (moduleCtx) => typeof find(this._consumers, ['type', moduleCtx.type]) === 'undefined'); + return Promise.all(toRemove.map((moduleCtx) => Promise.resolve() + .then(() => { + this.logger.debug(`Unloading consumer plug-in "${moduleCtx.type}"`); + + delete this._modules[moduleCtx.type]; + return moduleCtx.v2 && moduleCtx.instanceCtx.onUnload(); + }) + .catch((err) => this.logger.exception(`Uncaught exception on attemp to call ".onUnload()" for plug-in "${moduleCtx.type}":`, err)) + .then(() => { + moduleLoader.unload(moduleCtx.path); + this.logger.debug(`Consumer plug-in "${moduleCtx.type}" freed!`); + }))); + }) + .catch((err) => this.logger.exception('Uncaught exception on attemp to free consumer plug-ins', err)); +} + +/** + * Initialize ConsumersService + * + * @this ConsumersService + * + * @returns {Promise} resolve once instance initialzied + */ +function init() { + const supportedModules = []; + // build allowed list of consumers + return miscUtil.fs.readdir(this.pluginsDir) + .then((content) => { + const entryPoints = filter(content, (fdir) => !fdir.startsWith('.')) + .map((fdir) => [fdir, path.join(this.pluginsDir, fdir, ENTRY_FILE)]); + return Promise.all(entryPoints.map(([fdir, entryPoint]) => Promise.resolve() + .then(() => miscUtil.fs.access(entryPoint)) // should use `mode`? + .then(() => miscUtil.fs.stat(entryPoint)) + .then((stats) => { + if (stats.isFile()) { + supportedModules.push(fdir); + } + }) + .catch((err) => this.logger.debugException(`Unable to access "${entryPoint}"`, err)))); + }) + .then(() => { + supportedModules.sort((a, b) => { + /* Storing case insensitive comparison */ + const comparison = a.toLowerCase().localeCompare(b.toLowerCase()); + /* If strings are equal in case insensitive comparison + * then return case sensitive comparison instead */ + return comparison === 0 + ? a.localeCompare(b) + : comparison; + }); + if (supportedModules.length > 0) { + this.logger.info(`Following consumers detected: ${supportedModules.join(', ')}`); + } else { + this.logger.warning('No consumers plug-ins detected!'); + } + this._supportedModules = supportedModules; + }); +} + +/** + * Load consumer instances + * + * @this ConsumersService + * + * @param {ConsumerComponent[]} consumers - configured consumers + * + * @returns {Promise} resolved once desired consumers loaded + */ +function loadConsumers(consumers) { + return Promise.resolve() + .then(() => { + const toLoad = filter(consumers, (consumerCfg) => !consumerCfg.skipUpdate && consumerCfg.enable); + return Promise.all(toLoad.map((consumerCfg) => Promise.resolve() + .then(() => { + if (!this._supportedModules.includes(consumerCfg.type)) { + this.logger.warning(`Unable to initialize consumer "${consumerCfg.traceName}" (${consumerCfg.type}): plug-in "${consumerCfg.type}" does not exist!`); + return; + } + const moduleCtx = this._modules[consumerCfg.type]; + if (typeof moduleCtx === 'undefined') { + this.logger.warning(`Unable to initialize consumer "${consumerCfg.traceName}" (${consumerCfg.type}): plug-in "${consumerCfg.type}" not loaded!`); + return; + } + + this.logger.debug(`Initializing consumer "${consumerCfg.traceName}" (${consumerCfg.type})`); + + const cconfig = { + fullName: consumerCfg.traceName, + id: consumerCfg.id, + logger: moduleCtx.logger.getChild(consumerCfg.traceName), + name: consumerCfg.name, + tracer: tracerMgr.fromConfig(consumerCfg.trace), + type: moduleCtx.type + }; + if (moduleCtx.v2) { + cconfig.tracer = (cconfig.tracer && cconfig.tracer.write.bind(cconfig.tracer)) + || dummyTracerWrite; + } + + const getConsumerConfigCtx = () => Object.assign( + {}, cconfig, { config: miscUtil.deepCopy(consumerCfg) } // copy to avoid modifications + ); + + let instanceCtx = null; + let cmetadata = null; + let promise = Promise.resolve(); + + if (moduleCtx.v2) { + promise = promise.then(() => moduleCtx.instanceCtx.createConsumer(getConsumerConfigCtx())) + .then((ctx) => { + [ + 'onData', + 'onLoad', + 'onUnload' + ].forEach((method) => { + if (typeof ctx[method] !== 'function') { + throw new Error(`Consumer plug-in instance "${moduleCtx.type}" has no required method "${method}"!`); + } + }); + + instanceCtx = ctx; + return instanceCtx.onLoad(getConsumerConfigCtx()); + }); + } else { + promise = promise.then(() => metadataUtil.getInstanceMetadata(getConsumerConfigCtx())) + .then((metadata) => { + if (!miscUtil.isObjectEmpty(metadata)) { + cmetadata = metadata; + } + }); + } + return promise.then(() => { + const consumerCtx = Object.assign({}, cconfig, { + allowsPull: moduleCtx.v2 + ? instanceCtx.allowsPull + : consumerCfg.class === CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, + allowsPush: moduleCtx.v2 + ? instanceCtx.allowsPush + : consumerCfg.class === CONFIG_CLASSES.CONSUMER_CLASS_NAME, + class: consumerCfg.class, + config: miscUtil.deepCopy(consumerCfg), + consumer: moduleCtx.v2 ? instanceCtx.onData.bind(instanceCtx) : moduleCtx.module, + instanceCtx, + metadata: cmetadata, + v2: moduleCtx.v2 + }); + this._consumers[consumerCtx.id] = consumerCtx; + this.logger.debug(`Consumer "${consumerCtx.fullName}" (${consumerCtx.type}) initialized!`); + }); + }) + .catch((err) => this.logger.exception(`Uncaught exception on attempt to load consumer plug-in instance "${consumerCfg.traceName}" (${consumerCfg.type}):`, err)))); + }) + .catch((err) => this.logger.exception('Uncaught exception on attemp to load consumers', err)); +} + +/** + * Load consumer plug-ins + * + * @this ConsumersService + * + * @param {ConsumerComponent[]} consumers - configured consumers + * + * @returns {Promise} resolved once desired consumers plug-ins loaded + */ +function loadModules(consumers) { + return Promise.resolve() + .then(() => { + const toLoad = filter( + Object.keys( + groupBy( + filter(consumers, (consumerCfg) => !consumerCfg.skipUpdate && consumerCfg.enable), + 'type' + ) + ), + (ctype) => typeof this._modules[ctype] === 'undefined' + ); + return Promise.all(toLoad.map((ctype) => Promise.resolve() + .then(() => { + if (!this._supportedModules.includes(ctype)) { + this.logger.warning(`Unable to load consumer plug-in "${ctype}": unknown type`); + return; + } + this.logger.debug(`Load consumer plug-in "${ctype}"`); + + const cpath = path.join(this.pluginsDir, ctype); + const cmodule = moduleLoader.load(cpath); + if (cmodule === null) { + this.logger.warning(`Unable to load consumer plug-in "${ctype}": can't load "${cpath}" plug-in`); + return; + } + + const mlogger = this.logger.getChild(ctype); + const v2 = typeof cmodule === 'object' && typeof cmodule.load === 'function'; + + let instanceCtx = null; + let promise = Promise.resolve(); + + const mconfig = { + logger: mlogger, + name: ctype, + path: cpath + }; + const getModuleConfig = () => Object.assign({}, mconfig); + + if (v2) { + promise = promise.then(() => cmodule.load(getModuleConfig())) + .then((ctx) => { + [ + 'createConsumer', + 'deleteConsumer', + 'onLoad', + 'onUnload' + ].forEach((method) => { + if (typeof ctx[method] !== 'function') { + throw new Error(`Consumer plug-in "${ctype}" has no required method "${method}" (API v2)!`); + } + }); + instanceCtx = ctx; + return instanceCtx.onLoad(getModuleConfig()); + }); + } else if (typeof cmodule !== 'function') { + throw new Error(`Consumer plug-in "${ctype}" should export function (API v1)`); + } + return promise.then(() => { + this._modules[ctype] = { + instanceCtx, + logger: mlogger, + module: cmodule, + path: cpath, + type: ctype, + v2 + }; + this.logger.debug(`Consumer plug-in "${ctype}" loaded (API v${v2 ? 2 : 1})!`); + }); + }) + .catch((err) => { + this.logger.exception(`Uncaught exception on attempt to load consumer plug-in "${ctype}":`, err); + moduleLoader.unload(path.join(this.pluginsDir, ctype)); + }))); + }) + .catch((err) => this.logger.exception('Uncaught exception on attemp to load consumer plug-ins', err)); +} + +/** + * @this ConsumersService + * + * @param {Configuration} config + */ +function onConfigEvent(config) { + Promise.resolve() + .then(() => { + this.logger.verbose('Config "change" event'); + + const consumers = configUtil.getTelemetryConsumers(config); + consumers.push(...configUtil.getTelemetryPullConsumers(config)); + + return Promise.resolve() + .then(() => loadModules.call(this, consumers)) + .then(() => freeConsumers.call(this, consumers)) + .then(() => loadConsumers.call(this, consumers)) + .then(() => freeModules.call(this)); + }) + .catch((err) => { + this.logger.exception('Error caught on attempt to apply configuration to Consumers Manager:', err); + }) + .then(() => emitConsumersUpdate.call(this)); +} + +/** + * Emits `consumers.updated` event + * + * @this ConsumersService + */ +function emitConsumersUpdate() { + this.logger.info(`${this.numberOfConsumers} active consumer(s)`); + this.logger.info(`${this.numberOfModules} consumer plug-in(s) loaded`); + + const consumers = Object.values(this._consumers); + + // notify subscribers once config applied + this.ee.safeEmitAsync('consumers.updated', () => consumers.map((consumerCtx) => ({ + allowsPull: consumerCtx.allowsPull, + allowsPush: consumerCtx.allowsPush, + class: consumerCtx.class, + config: consumerCtx.config, // TODO: remove later once data pipeline updated + consumer: consumerCtx.consumer, + fullName: consumerCtx.fullName, + id: consumerCtx.id, + logger: consumerCtx.logger, // TODO: remove later once all consumers updated + metadata: consumerCtx.metadata, // TODO: remove later once all consumers updated + name: consumerCtx.name, + tracer: consumerCtx.tracer, // TODO: remove later once all consumers updated + type: consumerCtx.type, // TODO: remove later once all consumers updated + v2: consumerCtx.v2 // TODO: remove later once all consumers updated + }))); + + this.ee.safeEmitAsync('config.applied', { + consumers: this.numberOfConsumers, + modules: this.numberOfModules + }); +} + +module.exports = ConsumersService; + +/** + * @typedef ConsumerCtx + * @type {object} + * @property {boolean} allowsPull - allows PULL events + * @property {boolean} allowsPush - allows PUSH events + * @property {'Telemetry_Consumer' | 'Telemetry_Pull_Consumer'} class // TODO: remove later once data pipeline updated + * @property {configUtil.Configuration} config // TODO: remove later once data pipeline updated + * @property {ConsumerHandlerV1 | ConsumerHandlerV2} consumer + * @property {string} fullName + * @property {string} id + * @property {Logger} logger // TODO: remove later once all consumers updated + * @property {null | object} metadata // TODO: remove later once all consumers updated + * @property {string} name + * @property {function} tracer // TODO: remove later once all consumers updated + * @property {string} type // TODO: remove later once all consumers updated + * @property {boolean} v2 // TODO: remove later once all consumers updated + */ +/** + * @event consumers.updated + * @type {GetConsumers} + */ +/** + * @event config.applied + * @type {object} + * @property {integer} consumers - number of active consumers + * @property {integer} modules - number of loaded modules + */ +/** + * @callback ConsumerHandlerV1 + * @param {DataEventCtxV1} eventCtx + */ +/** + * @callback ConsumerHandlerV2 + * @param {DataEventCtxV2 | DataEventCtxV2[]} dataCtx + * @param {number} eventMask + * @param {null | ConsumerCallback} + */ +/** + * @callback GetConsumers + * + * @returns {function(): ConsumerCtx[]} + */ diff --git a/src/lib/consumers/shared/http2patch.js b/src/lib/consumers/shared/http2patch.js index fbacd9e4..20bb4aa6 100644 --- a/src/lib/consumers/shared/http2patch.js +++ b/src/lib/consumers/shared/http2patch.js @@ -19,6 +19,10 @@ const miscUtil = require('../../utils/misc'); const logger = require('../../logger').getChild('http2patch'); +/** + * NOTE: HTTP2 + grpc is broken on node 8.11.1 again + */ + // returns same Symbol every time const kPatched = Symbol.for('tsPatchedHttp2'); @@ -266,9 +270,7 @@ function patchHttp2Lib() { return http2[kPatched]; } const nodeVersion = process.version.slice(1); -if (miscUtil.compareVersionStrings(nodeVersion, '<', '8.11.1')) { - logger.debug('Don\'t need to patch "http2" module - minimal node.js version is 8.11.1!'); -} else if (miscUtil.compareVersionStrings(nodeVersion, '>', '8.13')) { +if (miscUtil.compareVersionStrings(nodeVersion, '>', '8.13')) { logger.debug('Don\'t need to patch "http2" module - is up-to-date already!'); } else { logger.warning('Patching "http2" module'); diff --git a/src/lib/customKeywords.js b/src/lib/customKeywords.js index f5b0a766..d8a364cd 100644 --- a/src/lib/customKeywords.js +++ b/src/lib/customKeywords.js @@ -17,7 +17,6 @@ 'use strict'; const Ajv = require('ajv'); -const fs = require('fs'); const constants = require('./constants'); const util = require('./utils/misc'); const deviceUtil = require('./utils/device'); @@ -375,15 +374,14 @@ function hostConnectivityCheck(schemaCtx, dataCtx) { * @returns {Function} that returns {Promise} resolved once path is valid */ function fsPathExistsCheck(schemaCtx, dataCtx) { - return () => new Promise((resolve, reject) => { - fs.access(dataCtx.data, (fs.constants || fs).R_OK, (err) => { - if (err) { - reject(new Error(`Unable to access path "${dataCtx.data}": ${err}`)); - } else { - resolve(true); - } - }); - }); + return async () => { + try { + await util.fs.access(dataCtx.data, util.fs.constants.R_OK); + } catch (error) { + throw new Error(`Unable to access path "${dataCtx.data}": ${error}`); + } + return true; + }; } /** @@ -440,21 +438,6 @@ function declarationClassCheck(schemaCtx, dataCtx) { return true; } -/** - * Checks the current node version and compares it to the field value - * - * @throws {Error} when reference is invalid - * @returns {Boolean} true if reference is valid - */ -function nodeSupportVersionCheck(schemaObj) { - const requestedNodeVersion = schemaObj.schema; - const currentNodeVersion = util.getRuntimeInfo().nodeVersion; - if (util.compareVersionStrings(currentNodeVersion, '<', requestedNodeVersion)) { - throw new Error(`requested node version: ${requestedNodeVersion} , current node version: ${currentNodeVersion}`); - } - return true; -} - /** * Expand JSON pointer * @@ -487,6 +470,28 @@ function f5expandCheck(schemaCtx, dataCtx) { } return true; } + +/** + * Check that the value does not exceed runtime.maxHeapSize or DEFAULT_HEAP_SIZE + * + * @param {SchemaCtx} schemaCtx - schema context + * @param {DataCtx} dataCtx - data context + * + * @returns {Function} that returns {Promise} resolved if value is <= configured heapSize + */ +function heapSizeLimitCheck(schemaCtx, dataCtx) { + return () => { + const controls = dataCtx.rootData[dataCtx.dataPath.split('/')[1]]; + const heapSizeVal = (controls.runtime + && controls.runtime.maxHeapSize) + || constants.APP_THRESHOLDS.MEMORY.DEFAULT_HEAP_SIZE; + + if (dataCtx.data > heapSizeVal) { + throw new Error(`Value should not be greater than V8's heap size: ${heapSizeVal} MB.`); + } + return true; + }; +} /** * Validators block end */ @@ -601,40 +606,10 @@ function createValidationFunction(keyword, func) { module.exports = { asyncOrder: [ - ['hostConnectivityCheck', 'pathExists'], + ['heapSizeLimitCheck', 'hostConnectivityCheck', 'pathExists'], ['f5secret'] ], keywords: { - f5secret: { - type: 'object', - errors: true, - modifying: true, - metaSchema: { - type: 'boolean' - }, - validate: createValidationFunction('f5secret', f5secretCheck) - }, - hostConnectivityCheck: { - type: 'string', - errors: true, - modifying: false, - metaSchema: { - type: 'boolean' - }, - validate: createValidationFunction('hostConnectivityCheck', hostConnectivityCheck) - }, - timeWindowMinSize: { - type: 'object', - errors: true, - modifying: false, - metaSchema: { - type: 'integer', - minimum: 1, - maximum: 1439, - description: 'Time window size in minutes. From 1m to 23h 59m.' - }, - validate: createValidationFunction('timeWindowMinSize', timeWindowSizeCheck) - }, declarationClass: { type: 'string', errors: true, @@ -669,34 +644,63 @@ module.exports = { }, validate: createValidationFunction('declarationClassProp', declarationClassPropCheck) }, - pathExists: { + f5expand: { type: 'string', errors: true, modifying: true, metaSchema: { - type: 'boolean', - description: 'Check that path exists' + type: 'boolean' }, - validate: createValidationFunction('pathExists', fsPathExistsCheck) + validate: createValidationFunction('f5expand', f5expandCheck) }, - f5expand: { - type: 'string', + f5secret: { + type: 'object', errors: true, modifying: true, metaSchema: { type: 'boolean' }, - validate: createValidationFunction('f5expand', f5expandCheck) + validate: createValidationFunction('f5secret', f5secretCheck) }, - nodeSupportVersion: { + heapSizeLimitCheck: { + type: 'integer', + errors: true, + modifying: false, + metaSchema: { + type: 'boolean' + }, + validate: createValidationFunction('heapSizeLimitCheck', heapSizeLimitCheck) + }, + hostConnectivityCheck: { + type: 'string', + errors: true, + modifying: false, + metaSchema: { + type: 'boolean' + }, + validate: createValidationFunction('hostConnectivityCheck', hostConnectivityCheck) + }, + pathExists: { + type: 'string', + errors: true, + modifying: true, + metaSchema: { + type: 'boolean', + description: 'Check that path exists' + }, + validate: createValidationFunction('pathExists', fsPathExistsCheck) + }, + timeWindowMinSize: { type: 'object', errors: true, modifying: false, metaSchema: { - type: 'string', - description: 'The lowest node version supported' + type: 'integer', + minimum: 1, + maximum: 1439, + description: 'Time window size in minutes. From 1m to 23h 59m.' }, - validate: createValidationFunction('nodeSupportVersion', nodeSupportVersionCheck) + validate: createValidationFunction('timeWindowMinSize', timeWindowSizeCheck) } } }; diff --git a/src/lib/dataPipeline.js b/src/lib/dataPipeline.js deleted file mode 100644 index e900e415..00000000 --- a/src/lib/dataPipeline.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const actionProcessor = require('./actionProcessor'); -const EVENT_TYPES = require('./constants').EVENT_TYPES; -const forwarder = require('./forwarder'); -const logger = require('./logger'); -const util = require('./utils/misc'); - -/** -* Pipeline to process data -* -* @param {Object} dataCtx - wrapper with data to process -* @param {Object} dataCtx.data - data to process -* @param {String} dataCtx.type - type of data to process -* @param {Object} [options] - options -* @param {module:util~Tracer} [options.tracer] - tracer instance -* @param {Object} [options.actions] - actions to apply to data (e.g. filters, tags) -* @param {Boolean} [options.noConsumers] - don't send data to consumers, instead just return it -* @param {Object} [options.deviceContext] - optional addtl context about device -* @returns {Promise} resolved with data if options.returnData === true otherwise will be resolved -* once data will be forwarded to consumers -*/ -function process(dataCtx, options) { - if (!isEnabled()) { - return Promise.resolve(); - } - - return new Promise((resolve) => { - options = options || {}; - // add telemetryEventCategory to data, fairly verbose name to avoid conflicts - if (typeof dataCtx.data.telemetryEventCategory === 'undefined') { - dataCtx.data.telemetryEventCategory = dataCtx.type; - } - // iHealthPoller doesn't support actions (filtering and tagging) - // raw events also should not go through any processing - if (dataCtx.type !== EVENT_TYPES.IHEALTH_POLLER && dataCtx.type !== EVENT_TYPES.RAW_EVENT - && !util.isObjectEmpty(options.actions)) { - actionProcessor.processActions(dataCtx, options.actions, options.deviceContext); - } - if (options.tracer) { - options.tracer.write(dataCtx); - } - let promise = Promise.resolve(); - if (!options.noConsumers) { - if (util.isObjectEmpty(dataCtx.data)) { - logger.verbose('Pipeline received empty object - no data to process, skip it (might be the result of the actions chain execution).'); - } else { - promise = promise.then(() => { - // detach forwarding process from here - forwarder.forward(dataCtx) - .catch((err) => logger.exception('Error on attempt to forward data to consumers', err)); - }); - } - } - promise.then(() => { - // logger.debug(`Pipeline processed data of type: ${dataCtx.type}`); - resolve(dataCtx); - }); - }); -} - -/** - * TEMP BLOCK OF CODE, REMOVE AFTER REFACTORING - */ -let processingEnabled = true; -let processingState = null; - -/** @param {restWorker.ApplicationContext} appCtx - application context */ -function initialize(appCtx) { - if (appCtx.resourceMonitor) { - if (processingState) { - logger.debug('Destroying existing ProcessingState instance'); - processingState.destroy(); - } - processingState = appCtx.resourceMonitor.initializePState( - onResourceMonitorUpdate.bind(null, true), - onResourceMonitorUpdate.bind(null, false) - ); - processingEnabled = processingState.enabled; - onResourceMonitorUpdate(processingEnabled); - } else { - logger.error('Unable to subscribe to Resource Monitor updates!'); - } -} - -/** @param {boolean} enabled - true if processing enabled otherwise false */ -function onResourceMonitorUpdate(enabled) { - processingEnabled = enabled; - if (enabled) { - logger.warning('Resuming data pipeline processing.'); - } else { - logger.warning('Incoming data will not be forwarded.'); - } -} - -/** - * Check if systemPoller(s) are running - * Toggled by monitor checks - * - * @returns {Boolean} - whether or not processing is enabled - */ - -function isEnabled() { - return processingEnabled; -} -/** - * TEMP BLOCK OF CODE END - */ - -module.exports = { - process, - initialize, - isEnabled -}; diff --git a/src/lib/actionProcessor.js b/src/lib/dataPipeline/actionProcessor.js similarity index 97% rename from src/lib/actionProcessor.js rename to src/lib/dataPipeline/actionProcessor.js index 1146f48c..a65d0e1c 100644 --- a/src/lib/actionProcessor.js +++ b/src/lib/dataPipeline/actionProcessor.js @@ -16,10 +16,10 @@ 'use strict'; -const logger = require('./logger'); +const logger = require('../logger'); const dataTagging = require('./dataTagging'); -const util = require('./utils/misc'); -const dataUtil = require('./utils/data'); +const util = require('../utils/misc'); +const dataUtil = require('../utils/data'); /** * Process actions like filtering or tagging diff --git a/src/lib/dataFilter.js b/src/lib/dataPipeline/dataFilter.js similarity index 88% rename from src/lib/dataFilter.js rename to src/lib/dataPipeline/dataFilter.js index a30b2180..d3ebc2e8 100644 --- a/src/lib/dataFilter.js +++ b/src/lib/dataPipeline/dataFilter.js @@ -16,8 +16,10 @@ 'use strict'; -const util = require('./utils/misc'); -const dataUtil = require('./utils/data'); +/* eslint-disable no-unused-expressions */ + +const util = require('../utils/misc'); +const dataUtil = require('../utils/data'); /** * Data Filter Class @@ -29,7 +31,7 @@ class DataFilter { * @param {Component} config - consumer config object */ constructor(consumerConfig) { - this.excludeList = {}; + this.excludeList = null; applyGlobalFilters.call(this, consumerConfig); } @@ -44,7 +46,7 @@ class DataFilter { */ apply(dataCtx) { const dataCtxCopy = util.deepCopy(dataCtx); - applyExcludeList.call(this, dataCtxCopy.data); + this.excludeList && applyExcludeList.call(this, dataCtxCopy.data); return dataCtxCopy; } } @@ -63,7 +65,7 @@ class DataFilter { function applyGlobalFilters(config) { // tmstats is only supported by Splunk legacy until users can specify desired tables if (config.type !== 'Splunk' || config.format !== 'legacy') { - this.excludeList = Object.assign(this.excludeList, { tmstats: true }); + this.excludeList = { tmstats: true }; } } @@ -92,6 +94,4 @@ function applyExcludeList(data) { }); } -module.exports = { - DataFilter -}; +module.exports = DataFilter; diff --git a/src/lib/dataTagging.js b/src/lib/dataPipeline/dataTagging.js similarity index 95% rename from src/lib/dataTagging.js rename to src/lib/dataPipeline/dataTagging.js index 2b4fb585..d45b794f 100644 --- a/src/lib/dataTagging.js +++ b/src/lib/dataPipeline/dataTagging.js @@ -16,12 +16,12 @@ 'use strict'; -const properties = require('./properties.json'); -const normalizeUtil = require('./utils/normalize'); -const dataUtil = require('./utils/data'); -const util = require('./utils/misc'); -const systemStatsUtil = require('./utils/systemStats'); -const EVENT_TYPES = require('./constants').EVENT_TYPES; +const dataUtil = require('../utils/data'); +const EVENT_TYPES = require('../constants').EVENT_TYPES; +const normalizeUtil = require('../utils/normalize'); +const properties = require('../properties.json'); +const util = require('../utils/misc'); +const systemStatsUtil = require('../systemPoller/utils'); /** * Applies the tags to the data @@ -50,6 +50,7 @@ function addTags(dataCtx, actionCtx, deviceCtx) { // Apply tags to default locations (where addKeysByTag is true) for system info if (!dataCtx.isCustom) { Object.keys(properties.stats).forEach((statKey) => { + // TODO: remove or move to later stage const statProp = systemStatsUtil.renderProperty(deviceCtx, util.deepCopy( properties.stats[statKey] )); diff --git a/src/lib/dataPipeline/forwarder.js b/src/lib/dataPipeline/forwarder.js new file mode 100644 index 00000000..c9e40033 --- /dev/null +++ b/src/lib/dataPipeline/forwarder.js @@ -0,0 +1,159 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const actionProcessor = require('./actionProcessor'); +const DataFilter = require('./dataFilter'); + +/** + * @module forwarder + * + * @typedef {import('../consumers/api').ConsumerCallback} ConsumerCallback + * @typedef {import('../consumers').ConsumerCtx} ConsumerCtx + * @typedef {import('./dataPipeline').DataEventCtxV1} DataEventCtxV1 + * @typedef {import('./dataPipeline').DataEventCtxV2} DataEventCtxV2 + */ + +/** + * Consumer Handler Base Class + */ +class ConsumerHandlerBase { + /** @param {ConsumerCtx} consumerCtx */ + constructor(consumerCtx) { + this.consumer = consumerCtx.consumer; + this.ctx = consumerCtx; + this.dataActions = consumerCtx.config.actions; + this.filter = new DataFilter(consumerCtx.config); + this.id = consumerCtx.id; + this.logger = consumerCtx.logger.getChild('forwarder'); + } + + /** + * @param {DataCtx} dataCtx - data to pre-process + * + * @returns {DataCtx} deep copied and pre-processed data + */ + _preprocessDataCtx(dataCtx) { + // copies data + dataCtx = this.filter.apply(dataCtx); + + try { + // does modifications in place + this.dataActions && actionProcessor.processActions(dataCtx, this.dataActions); + } catch (err) { + // Catch the error, but do not exit + this.logger.exception('Error on attempt to process data actions', err); + } + + return dataCtx; + } + + /** + * @param {DataCtx | DataCtx[]} dataCtxs - data to pre-process + * + * @returns {DataCtx | DataCtx[]} deep copied and pre-processed data + */ + _preprocessAllDataCtxs(dataCtxs) { + if (Array.isArray(dataCtxs)) { + dataCtxs = dataCtxs.map((dataCtx) => this._preprocessDataCtx(dataCtx)); + } else { + dataCtxs = this._preprocessDataCtx(dataCtxs); + } + return dataCtxs; + } + + /** + * Process data + * + * @param {DataEventCtxV2 | DataEventCtxV2[]} dataCtx - data context + * @param {number} emask - event mask + * @param {ConsumerCallback} [callback] - callback to call once data sent or processed + */ + process(dataCtxs) { + return this._preprocessAllDataCtxs(dataCtxs); + } +} + +/** + * Consumer Handler V1 Class + * + * @property {DataEventCtxV1} dataEventCtx + */ +class ConsumerHandlerV1 extends ConsumerHandlerBase { + /** @param {ConsumerCtx} consumerCtx */ + constructor(consumerCtx) { + super(consumerCtx); + this.dataEventCtx = { + config: consumerCtx.config, + logger: consumerCtx.logger, + metadata: consumerCtx.metadata, + tracer: consumerCtx.tracer + }; + } + + /** + * Process data + * + * @param {DataEventCtxV2} dataCtx - data context + */ + process(dataCtx) { + return Promise.resolve() + .then(() => this.consumer( + Object.assign({ + event: super.process(dataCtx) + }, this.dataEventCtx) + )) + .catch((err) => this.logger.exception('Error on attempt to forward data to consumer', err)); + } +} + +/** + * Consumer Handler V2 Class + */ +class ConsumerHandlerV2 extends ConsumerHandlerBase { + /** + * Process data + * + * @param {DataEventCtxV2 | DataEventCtxV2[]} dataCtx - data context + * @param {number} emask - event mask + * @param {ConsumerCallback} [callback] - callback to call once data sent or processed + * + * @returns {any} once data sent or processed + */ + process(dataCtx, emask, callback) { + return Promise.resolve() + .then(() => this.consumer(super.process(dataCtx), emask, callback)) + .catch((err) => this.logger.exception('Error on attempt to forward data to consumer', err)); + } +} + +/** + * Make consumer handler + * + * @param {ConsumerCtx} consumerCtx + * + * @returns {ConsumerHandlerBase} + */ +module.exports = function makeConsumerHandler(consumerCtx) { + return new (consumerCtx.v2 ? ConsumerHandlerV2 : ConsumerHandlerV1)(consumerCtx); +}; + +// TODO: add return type for consumer handler +// TODO: add properties description +// TODO: .process SHOULD NOT use promises! diff --git a/src/lib/dataPipeline/index.js b/src/lib/dataPipeline/index.js new file mode 100644 index 00000000..a1ee722c --- /dev/null +++ b/src/lib/dataPipeline/index.js @@ -0,0 +1,203 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const actionProcessor = require('./actionProcessor'); +const { DATA_PIPELINE, EVENT_TYPES } = require('../constants'); +const makeForwarder = require('./forwarder'); +const Service = require('../utils/service'); +const util = require('../utils/misc'); + +/** + * @module dataPipeline + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').ConsumerComponent} ConsumerComponent + * @typedef {import('../consumers').GetConsumers} GetConsumers + * @typedef {import('../logger').Logger} Logger + * @typedef {import('../resourceMonitor').PStateBuilder} PStateBuilder + * @typedef {import('../utils/tracer').Tracer} Tracer + */ + +const EE_NAMESPACE = 'datapipeline'; // namespace for global events +let instance = null; // temp solution until + +/** Data Pipeline Service Class */ +class DataPipelineService extends Service { + /** @returns {boolean} true when data processing enabled */ + get processingEnabled() { + return this._pstate ? this._pstate.enabled : true; + } + + /** @inheritdoc */ + _onStart() { + instance = this; // temp code + this._forwarders = {}; + this._pstate = null; + + this._registerEvents(); + } + + /** @inheritdoc */ + _onStop() { + this._eventListeners.forEach((listener) => listener.off()); + this._eventListeners = null; + + if (this._pstate && this._pstate.destroyed === false) { + this._pstate.destroy(); + } + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + + instance = null; // temp code + this._forwarders = null; + this._pstate = null; + } + + /** @param {ApplicationEvents} appEvents - global event emitter */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._eventListeners = [ + appEvents.on('consumers.change', onConsumersChange.bind(this), { objectify: true }), + appEvents.on('resmon.pstate', initializeProcessingState.bind(this), { objectify: true }) + ]; + this.logger.debug('Subscribed to Consumers updates.'); + this.logger.debug('Subscribed to Resource Monitor updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { change: 'change' }, + { 'config.applied': 'config.done' } + ]); + }; + } + + /** + * Process data + * + * @param {DataEventCtx} dataCtx - wrapper with data to process + * @param {object} [options] - options + * @param {object}[options.actions] - actions to apply to data (e.g. filters, tags) + * @param {boolean} [options.catchErrors] - catch errors + * @param {object} [options.deviceContext] - optional addtl context about device + * @param {Tracer} [options.tracer] - tracer instance (from data source) + * + * @returns {Promise} resolved with data if options.returnData === true otherwise will be resolved + * once data will be forwarded to consumers + */ + process(dataCtx, event = DATA_PIPELINE.PUSH_EVENT, callback = null, options = {}) { + const p = new Promise((resolve) => { + const ids = dataCtx.destinationIds; + + if (this.processingEnabled === true) { + // add telemetryEventCategory to data, fairly verbose name to avoid conflicts + if (typeof dataCtx.data === 'object' && typeof dataCtx.data.telemetryEventCategory === 'undefined') { + dataCtx.data.telemetryEventCategory = dataCtx.type; + } + // iHealthPoller doesn't support actions (filtering and tagging) + // raw events also should not go through any processing + if (!(dataCtx.type === EVENT_TYPES.IHEALTH_POLLER + || dataCtx.type === EVENT_TYPES.RAW_EVENT + || util.isObjectEmpty(options.actions) + )) { + actionProcessor.processActions(dataCtx, options.actions, options.deviceContext); + } + + options.tracer && options.tracer.write(dataCtx); + + if (Array.isArray(ids) && ids.length > 0 && !util.isObjectEmpty(dataCtx.data)) { + ids.forEach((idx) => { + this._forwarders[idx] && this._forwarders[idx].process(dataCtx, event, callback); + }); + } + } + resolve(); + }); + return (options.catchErrors + ? p.catch((error) => this.logger.exception('Uncaught error on attempt to forward data', error)) + : p).then(() => dataCtx); + } +} + +/** + * @this DataPipelineService + * + * @param {GetConsumers} getConsumers + */ +function onConsumersChange(getConsumers) { + Promise.resolve() + .then(() => { + this.logger.debug('Consumers "change" event'); + + getConsumers().forEach((consumerCtx) => { + const fwd = makeForwarder(consumerCtx); + this._forwarders[fwd.id] = fwd; + }); + + this.ee.safeEmitAsync('config.applied', { + numberOfForwarders: Object.keys(this._forwarders).length + }); + }); +} + +/** + * Initialize Processing State handling + * + * @this DataPipelineService + * + * @param {PStateBuilder} makePState + */ +function initializeProcessingState(makePState) { + this._pstate = makePState( + // on enable + () => this.logger.warning('Resuming data pipeline processing.'), + // on disable + () => this.logger.warning('Incoming data will not be forwarded.') + ); +} + +module.exports = DataPipelineService; +module.exports.process = function () { + return instance.process.apply(instance, arguments); +}; + +/** + * @typedef {object} DataEventCtxV2 + * @property {any} data + * @property {string} type + * @property {boolean} [isCustom] + */ + +/** + * @typedef DataEventCtx + * @type {DataEventCtxV2} + * @property {string} sourceId + * @property {string[]} [destinationIds] + */ +/** + * @typedef DataEventCtxV1 + * @type {object} + * @property {DataEventCtxV2 | DataEventCtxV2[]} event + * @property {ConsumerComponent} config + * @property {null | Tracer} tracer + * @property {Logger} logger + * @property {null | object} metadata + */ diff --git a/src/lib/declarationHistory/index.js b/src/lib/declarationHistory/index.js new file mode 100644 index 00000000..d4081e16 --- /dev/null +++ b/src/lib/declarationHistory/index.js @@ -0,0 +1,98 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const Recorder = require('./recorder'); +const Service = require('../utils/service'); + +/** + * @module declarationHistory + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + */ + +const EE_NAMESPACE = 'dechistory'; + +/** + * Declaration History Class + * + * @fires reported + */ +class DeclarationHistory extends Service { + /** Configure and start the service */ + _onStart() { + this._recorder = new Recorder(); + } + + /** Stop the service */ + _onStop() { + return Promise.resolve() + .then(() => this._recorder.destroy()); + } + + /** @returns {Promise} resolved with true when service destroyed or if it was destroyed already */ + destroy() { + this._configUpdateListeners.forEach((listener) => listener.off()); + this._configUpdateListeners = []; + + return super.destroy() + .then((ret) => { + this._offMyEvents + && this._offMyEvents.off() + && (this._offMyEvents = null); + + return ret; + }); + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + this._configUpdateListeners = [ + 'config.prevalidationSucceed', // simple pre-validation + 'config.received', // raw declaration received + 'config.validationFailed', // validation failed + 'config.validationSucceed' // full validation succeed + ] + .map((event) => appEvents.on(event, onConfigEvent.bind(this, event), { objectify: true })); + + this.logger.debug('Subscribed to Configuration updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { recorded: 'recorded' } + ]); + } +} + +/** + * @this DeclarationHistory + * + * @param {string} event + * @param {object} data + * @param {object} data.declaration + * @param {object} data.metadata + * @param {object} data.transactionID + */ +function onConfigEvent(event, data) { + Promise.resolve() + .then(() => this._recorder.record(event, data)) + .catch((err) => this.logger.debugException('Unable to wirte a new declaration history entry', err)) + .then(() => this.ee.safeEmitAsync('recorded')); +} + +module.exports = DeclarationHistory; diff --git a/src/lib/declarationHistory/recorder.js b/src/lib/declarationHistory/recorder.js new file mode 100644 index 00000000..0ed01083 --- /dev/null +++ b/src/lib/declarationHistory/recorder.js @@ -0,0 +1,59 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const constants = require('../constants'); +const tracer = require('../utils/tracer'); + +/** + * @private + * + * @module declarationHistory/recorder + */ + +/** + * Declaration Recorder class + */ +class DeclarationRecorder { + constructor() { + this._tracer = tracer.create( + constants.ACTIVITY_RECORDER.DECLARATION_TRACER.PATH, + { + maxRecords: constants.ACTIVITY_RECORDER.DECLARATION_TRACER.MAX_RECORDS + } + ); + } + + /** @returns {Promise} resolved once instance destroyed */ + destroy() { + return this._tracer.stop(); + } + + /** + * Record event + * + * @param {string} event + * @param {any} data + * + * @returns {Promise} resolved once data recorded + */ + record(event, data) { + return this._tracer.write({ event, data }); + } +} + +module.exports = DeclarationRecorder; diff --git a/src/lib/declarationValidator.js b/src/lib/declarationValidator.js index 78ae883d..8ae7dff3 100644 --- a/src/lib/declarationValidator.js +++ b/src/lib/declarationValidator.js @@ -26,6 +26,7 @@ const util = require('./utils/misc'); const actionsSchema = require('../schema/latest/actions_schema.json'); const baseSchema = require('../schema/latest/base_schema.json'); +const commonSchema = require('../schema/latest/common_schema.json'); const consumerSchema = require('../schema/latest/consumer_schema.json'); const controlsSchema = require('../schema/latest/controls_schema.json'); const endpointsSchema = require('../schema/latest/endpoints_schema.json'); @@ -75,6 +76,7 @@ module.exports = { const schemas = { actions: actionsSchema, base: baseSchema, + common: commonSchema, consumer: consumerSchema, pullConsumer: pullConsumerSchema, controls: controlsSchema, @@ -146,31 +148,33 @@ module.exports = { return Promise.resolve(data); } - const deferred = context.deferred; - const processDeferred = (idx) => { - if (idx >= customKeywords.asyncOrder.length) { - return Promise.resolve(); - } - const promises = []; - const keywords = customKeywords.asyncOrder[idx]; - keywords.forEach((keyword) => { - if (deferred[keyword]) { - deferred[keyword].forEach((deferredFn) => { - promises.push(deferredFn()); - }); - } - }); - return promiseUtil.allSettled(promises) + // group all callbacks according to async validation order/priority + const deferred = customKeywords.asyncOrder + .map((group) => { + const v = group.reduce((acc, key) => { + acc.push(...(context.deferred[key] || [])); + return acc; + }, []); + return v; + }) + .filter((group) => group.length); + + // keep groups ordering + return promiseUtil.loopForEach( + deferred, + // run at callbacks from a particular group at the same time + (group) => promiseUtil.allSettled( + group.map((fn) => Promise.resolve().then(fn)) + ) .then((results) => { const innerErrors = results.filter((r) => typeof r.reason !== 'undefined'); if (innerErrors.length) { - return Promise.reject(new Error(util.stringify(processErrors(innerErrors)))); + return Promise.reject(new Error(util.stringify(innerErrors))); } - return processDeferred(idx + 1); - }); - }; - return processDeferred(0) - .then(() => Promise.resolve(data)); + return Promise.resolve(); + }) + ) + .then(() => data); } }; diff --git a/src/lib/endpointLoader.js b/src/lib/endpointLoader.js deleted file mode 100644 index 49d1de19..00000000 --- a/src/lib/endpointLoader.js +++ /dev/null @@ -1,409 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const constants = require('./constants'); -const deviceUtil = require('./utils/device'); -const logger = require('./logger'); -const promiseUtil = require('./utils/promise'); -const retryPromise = require('./utils/promise').retry; -const util = require('./utils/misc'); - -/** @module EndpointLoader */ - -/** - * Options to use to expand reference - * - * @typedef {Object} ExpandReferencesOpts - * @property {String} [endpointSuffix] - URI suffix to use to modify link - * @property {Boolean} [includeStats] - include response from /stats - */ -/** - * References to expand - * - * @typedef {Object.} newEndpoints - list of endpoints to add - */ -EndpointLoader.prototype.setEndpoints = function (newEndpoints) { - if (Array.isArray(newEndpoints)) { - this.endpoints = {}; - newEndpoints.forEach((endpoint) => { - // if 'name' presented then use it as unique ID - // otherwise use path prop - this.endpoints[endpoint.name || endpoint.path] = endpoint; - }); - } else { - this.endpoints = newEndpoints; - } -}; -/** - * Authenticate on target device - * - * @returns {Promise} Promise which is resolved when successfully authenticated - */ -EndpointLoader.prototype.auth = function () { - if (this.options.credentials.token) { - return Promise.resolve(); - } - const options = Object.assign({}, this.options.connection); - return deviceUtil.getAuthToken( - this.host, - this.options.credentials.username, - this.options.credentials.passphrase, - options - ) - .then((token) => { - this.options.credentials.token = token.token; - }); -}; -/** - * Load data from endpoint - * - * @param {String} endpoint - endpoint name/key to fetch data from - * @param {Object} [options] - function options - * @param {Object} [options.replaceStrings] - key/value pairs that replace matching strings in request body - * - * @returns {Promise} Promise resolved with FetchedData - */ -EndpointLoader.prototype.loadEndpoint = function (endpoint, options) { - let endpointObj = this.endpoints[endpoint]; - if (endpointObj === undefined) { - return Promise.reject(new Error(`Endpoint not defined: ${endpoint}`)); - } - // TODO: fix it later, right now it doesn't work with multiple concurrent connections - if (!endpointObj.ignoreCached && typeof this.cachedResponse[endpoint] !== 'undefined') { - return Promise.resolve(this.cachedResponse[endpoint]); - } - if ((options || {}).replaceStrings) { - endpointObj = Object.assign({}, endpointObj); - endpointObj.body = this.replaceBodyVars(endpointObj.body, options.replaceStrings); - } - return this.getAndExpandData(endpointObj) - .then((response) => { - this.cachedResponse[endpoint] = response; - return Promise.resolve(response); - }) - .catch((err) => { - this.logger.error(`Error: EndpointLoader.loadEndpoint: ${endpoint}: ${err}`); - return Promise.reject(err); - }); -}; - -/** - * Expand references - * - * @param {module:EndpointLoader~Endpoint} endpointObj - endpoint object - * @param {module:EndpointLoader~FetchedData} data - fetched data - * - * @returns {Promise>} resolved with array of FetchedData - */ -EndpointLoader.prototype.expandReferences = function (endpointObj, data) { - const promises = []; - const dataItems = data.data.items; - if (endpointObj.expandReferences && dataItems && Array.isArray(dataItems) && dataItems.length) { - // for now let's just support a single reference - const referenceKey = Object.keys(endpointObj.expandReferences)[0]; - const referenceObj = endpointObj.expandReferences[referenceKey]; - for (let i = 0; i < dataItems.length; i += 1) { - const item = dataItems[i][referenceKey]; - if (item && item.link) { - let referenceEndpoint = this.getURIPath(item.link); - // Process '/stats' endpoint first, before modifying referenceEndpoint url - if (referenceObj.includeStats) { - promises.push(this.getData(`${referenceEndpoint}/stats`, { name: i, refKey: referenceKey })); - } - if (referenceObj.endpointSuffix) { - referenceEndpoint = `${referenceEndpoint}${referenceObj.endpointSuffix}`; - } - promises.push(this.getData(referenceEndpoint, { name: i, refKey: referenceKey })); - } - } - } - return promiseUtil.allSettled(promises) - .then((results) => promiseUtil.getValues(results)); -}; -/** - * Fetch stats for each item - * - * @param {module:EndpointLoader~Endpoint} endpointObj - endpoint object - * @param {Object} data - data - * @param {String} data.name - name - * @param {Object} data.data - data to process - * - * @returns {Promise>}} resolved with array of FetchedData - */ -EndpointLoader.prototype.fetchStats = function (endpointObj, data) { - const promises = []; - const dataItems = data.data.items; - if (endpointObj.includeStats && dataItems && Array.isArray(dataItems) && dataItems.length) { - for (let i = 0; i < dataItems.length; i += 1) { - const item = dataItems[i]; - // check for selfLink property - if (item.selfLink) { - promises.push(this.getData(`${this.getURIPath(item.selfLink)}/stats`, { name: i })); - } - } - } - return promiseUtil.allSettled(promises) - .then((results) => promiseUtil.getValues(results)); -}; -/** - * Substitute data - * - * @param {module:EndpointLoader~FetchedData} baseData - base data - * @param {Array} dataArray - array of data to use for substitution - * @param {Boolean} shallowCopy - true if shallow copy required else - * original object will be used - */ -EndpointLoader.prototype.substituteData = function (baseData, dataArray, shallowCopy) { - if (!dataArray.length) { - return; - } - const baseDataItems = baseData.data.items; - dataArray.forEach((data) => { - try { - let dataToSubstitute; - if (shallowCopy === true) { - dataToSubstitute = Object.assign(data.data, baseDataItems[data.name]); - } else { - dataToSubstitute = data.data; - } - if (data.refKey) { - // if this is the first time substituting data, overwrite the containing object with data - // e.g. - // itemsRef: { - // link: 'http://objLink/objItems', - // isSubcollection: true - // } - // will become: - // itemsRef: { - // objItemProp1: 123 //data from link - // } - if (baseDataItems[data.name][data.refKey].link) { - baseDataItems[data.name][data.refKey] = dataToSubstitute; - } else { - // otherwise if same object has been previously substituted - // and we're merging new set of props from a different link (e.g. objItems/stats) - // then copy over the properties of the new dataToSubstitute - // e.g. - // itemsRef: { - // objItemProp1: 123 - // objItemProp2: true - // } - Object.assign(baseDataItems[data.name][data.refKey], dataToSubstitute); - } - } else { - baseDataItems[data.name] = dataToSubstitute; - } - } catch (e) { - // just continue - } - }); -}; -/** - * Get data for specific endpoint - * - * @param {String} uri - uri where data resides - * @param {Object} [options] - function options - * @param {String} [options.name] - name of key to store as, will override default of uri - * @param {String} [options.body] - body to send, sent via POST request - * @param {String} [options.refKey] - reference key - * @param {String[]} [options.endpointFields] - restrict collection to these fields - * @param {Boolean} [options.parseDuplicateKeys] - whether or not to support parsing JSON with duplicate keys - * - * @returns {Promise} resolved with FetchedData - */ -EndpointLoader.prototype.getData = function (uri, options) { - this.logger.verbose(`EndpointLoader.getData: loading data from URI = ${uri}`); - - options = options || {}; - const httpOptions = Object.assign({}, this.options.connection); - const parseDuplicateKeys = options.parseDuplicateKeys === true; - - httpOptions.credentials = { - username: this.options.credentials.username, - token: this.options.credentials.token - }; - if (parseDuplicateKeys) { - httpOptions.rawResponseBody = options.parseDuplicateKeys; - } - if (options.body) { - httpOptions.method = 'POST'; - httpOptions.body = options.body; - } - const retryOpts = { - maxTries: 3, - backoff: 100 - }; - const fullUri = options.endpointFields ? `${uri}?$select=${options.endpointFields.join(',')}` : uri; - return retryPromise(() => deviceUtil.makeDeviceRequest(this.host, fullUri, httpOptions), retryOpts) - .then((data) => { - if (parseDuplicateKeys) { - data = util.parseJsonWithDuplicateKeys(data.toString()); - } - const ret = { - name: options.name !== undefined ? options.name : uri, - data - }; - if (options.refKey) { - ret.refKey = options.refKey; - } - return ret; - }); -}; -/** - * Get data for specific endpoint (with some extra logic) - * - * @param {module:EndpointLoader~Endpoint} endpointObj - endpoint object - * @param {String} endpointObj.path - URI path to get data from - * @returns {Promise} resolved with FetchedData - */ -EndpointLoader.prototype.getAndExpandData = function (endpointObj) { - // baseData in this method is the data fetched from endpointObj.path - return this.getData(endpointObj.path, endpointObj) - // Promise below will be resolved with array of 2 elements: - // [ baseData, [refData, refData] ] - .then((baseData) => promiseUtil.allSettled([ - Promise.resolve(baseData), - this.expandReferences(endpointObj, baseData) - ])) - .then((results) => { - const dataArray = promiseUtil.getValues(results); - // dataArray === [ baseData, [refData, refData] ] - const baseData = dataArray[0]; - this.substituteData(baseData, dataArray[1], false); - return promiseUtil.allSettled([ - Promise.resolve(baseData), - this.fetchStats(endpointObj, baseData) - ]); - }) - // Promise below will be resolved with array of 2 elements: - // [ baseData, [statsData, statsData] ] - .then((results) => { - const dataArray = promiseUtil.getValues(results); - // dataArray === [ baseData, [statsData, statsData] ] - const baseData = dataArray[0]; - this.substituteData(baseData, dataArray[1], true); - return baseData; - }); -}; - -/** - * Replace variables in body with values - * - * @param {Object|String} body - request body - * @param {Object} keys - keys/vars to replace - * - * @returns {Object|String} - */ -EndpointLoader.prototype.replaceBodyVars = function (body, keys) { - let isObject = false; - if (typeof body !== 'string') { - isObject = true; - body = JSON.stringify(body); - } - Object.keys(keys).forEach((key) => { - body = body.replace(new RegExp(key), keys[key]); - }); - if (isObject) { - body = JSON.parse(body); - } - return body; -}; - -/** - * Get URI path - * - * @param {String} uri - URI - * - * @returns {String} URI path - */ -EndpointLoader.prototype.getURIPath = function (uri) { - return uri.replace('https://localhost', '').split('?')[0]; -}; - -module.exports = EndpointLoader; diff --git a/src/lib/errors.js b/src/lib/errors.js index d00da1d7..ffeda479 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -30,6 +30,15 @@ class BaseError extends Error {} */ class ConfigLookupError extends BaseError {} +/** + * Not Implemented Error + */ +class NotImplementedError extends Error { + constructor() { + super('Not implemented!'); + } +} + /** * Object not found in Config Error */ @@ -43,6 +52,7 @@ class ValidationError extends BaseError {} module.exports = { BaseError, ConfigLookupError, + NotImplementedError, ObjectNotFoundInConfigError, ValidationError }; diff --git a/src/lib/eventListener/dataPublisher.js b/src/lib/eventListener/dataPublisher.js index 4f5b3bb0..8ca4d37c 100644 --- a/src/lib/eventListener/dataPublisher.js +++ b/src/lib/eventListener/dataPublisher.js @@ -40,8 +40,10 @@ function sendDataToListener(data, listenerName, options) { const opts = options || {}; return Promise.resolve() .then(() => { - const eventListener = configUtil.getTelemetryListeners(configWorker.currentConfig, opts.namespace) - .find((el) => el.name === listenerName); + const eventListener = configUtil.getTelemetryListeners(configWorker.currentConfig, { + name: listenerName, + namespace: opts.namespace + })[0]; let error; const namespaceSuffix = `${opts.namespace ? ` in Namespace: ${opts.namespace}` : ''}.`; diff --git a/src/lib/eventListener/index.js b/src/lib/eventListener/index.js index 0cf341d7..61136a7e 100644 --- a/src/lib/eventListener/index.js +++ b/src/lib/eventListener/index.js @@ -17,10 +17,10 @@ 'use strict'; const configUtil = require('../utils/config'); -const configWorker = require('../config'); const constants = require('../constants'); const dataPipeline = require('../dataPipeline'); -const logger = require('../logger'); +const hrtimestamp = require('../utils/datetime').hrtimestamp; +const logger = require('../logger').getChild('eventListener'); const normalize = require('../normalize'); const onApplicationExit = require('../utils/misc').onApplicationExit; const promiseUtil = require('../utils/promise'); @@ -29,7 +29,11 @@ const StreamService = require('./streamService'); const stringify = require('../utils/misc').stringify; const tracerMgr = require('../tracerManager'); -/** @module EventListener */ +/** + * @module EventListener + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + */ const normalizationOpts = { global: properties.global, @@ -258,7 +262,12 @@ class EventListener { sourceId: this.id, destinationIds: this.destinationIds }; - const p = dataPipeline.process(dataCtx, { tracer: this.tracer, actions: this.actions }) + const p = dataPipeline.process( + dataCtx, + constants.DATA_PIPELINE.PUSH_EVENT, + null, + { tracer: this.tracer, actions: this.actions } + ) .catch((err) => this.logger.exception('EventListener:_processEvents unexpected error from dataPipeline:process', err)); promises.push(p); } @@ -376,57 +385,60 @@ EventListener.remove = function (listener) { }; // config worker change event -configWorker.on('change', (config) => { - logger.debug('configWorker change event in eventListener'); // helpful debug - const configuredListeners = configUtil.getTelemetryListeners(config); - const controls = configUtil.getTelemetryControls(config); - - // stop all removed listeners - EventListener.getAll().forEach((listener) => { - const configMatch = configuredListeners.find((n) => n.traceName === listener.name); - if (!configMatch) { - logger.debug(`Removing event listener - ${listener.name} [port = ${listener.messageStream.port}]. Reason - removed from configuration.`); - EventListener.remove(listener); - } - }); - // stop all disabled listeners - configuredListeners.forEach((listenerConfig) => { - const listener = EventListener.getByName(listenerConfig.traceName); - if (listener && listenerConfig.enable === false) { - logger.debug(`Removing event listener - ${listener.name} [port = ${listener.port}]. Reason - disabled.`); - EventListener.remove(listener); - } - }); +function onConfigChange(config) { + Promise.resolve() + .then(() => { + logger.debug('configWorker change event in eventListener'); // helpful debug + const configuredListeners = configUtil.getTelemetryListeners(config); + const controls = configUtil.getTelemetryControls(config); + + // stop all removed listeners + EventListener.getAll().forEach((listener) => { + const configMatch = configuredListeners.find((n) => n.traceName === listener.name); + if (!configMatch) { + logger.debug(`Removing event listener - ${listener.name} [port = ${listener.messageStream.port}]. Reason - removed from configuration.`); + EventListener.remove(listener); + } + }); + // stop all disabled listeners + configuredListeners.forEach((listenerConfig) => { + const listener = EventListener.getByName(listenerConfig.traceName); + if (listener && listenerConfig.enable === false) { + logger.debug(`Removing event listener - ${listener.name} [port = ${listener.port}]. Reason - disabled.`); + EventListener.remove(listener); + } + }); - configuredListeners.forEach((listenerConfig) => { - if (listenerConfig.skipUpdate || listenerConfig.enable === false) { - return; - } - // use name (prefixed if namespace is present) - const name = listenerConfig.traceName; - const port = listenerConfig.port; - - const msgPrefix = EventListener.getByName(name) ? 'Updating event' : 'Creating new event'; - logger.debug(`${msgPrefix} listener - ${name} [port = ${port}]`); - - const listener = EventListener.get(name, port, controls.listenerMode || 'buffer', controls.listenerStrategy || 'ring'); - listener.updateConfig({ - actions: listenerConfig.actions, - destinationIds: configUtil.getReceivers(config, listenerConfig).map((r) => r.id), - filterFunc: buildFilterFunc(listenerConfig), - id: listenerConfig.id, - tags: listenerConfig.tag, - tracer: tracerMgr.fromConfig(listenerConfig.trace), - inputTracer: tracerMgr.fromConfig(listenerConfig.traceInput) - }); - listener.updateRawDataHandling(); - }); + configuredListeners.forEach((listenerConfig) => { + if (listenerConfig.skipUpdate || listenerConfig.enable === false) { + return; + } + // use name (prefixed if namespace is present) + const name = listenerConfig.traceName; + const port = listenerConfig.port; + + const msgPrefix = EventListener.getByName(name) ? 'Updating event' : 'Creating new event'; + logger.debug(`${msgPrefix} listener - ${name} [port = ${port}]`); + + const listener = EventListener.get(name, port, controls.listenerMode || 'buffer', controls.listenerStrategy || 'ring'); + listener.updateConfig({ + actions: listenerConfig.actions, + destinationIds: configUtil.getReceivers(config, listenerConfig).map((r) => r.id), + filterFunc: buildFilterFunc(listenerConfig), + id: listenerConfig.id, + tags: listenerConfig.tag, + tracer: tracerMgr.fromConfig(listenerConfig.trace), + inputTracer: tracerMgr.fromConfig(listenerConfig.traceInput) + }); + listener.updateRawDataHandling(); + }); - return EventListener.receiversManager.stopAndRemoveInactive() - .then(() => EventListener.receiversManager.start()) - .then(() => logger.debug(`${EventListener.getAll().length} event listener(s) listening`)) - .catch((err) => logger.exception('Unable to start some (or all) of the event listeners', err)); -}); + return EventListener.receiversManager.stopAndRemoveInactive() + .then(() => EventListener.receiversManager.start()) + .then(() => logger.debug(`${EventListener.getAll().length} event listener(s) listening`)) + .catch((err) => logger.exception('Unable to start some (or all) of the event listeners', err)); + }); +} onApplicationExit(() => { EventListener.getAll().map(EventListener.remove); @@ -436,37 +448,45 @@ onApplicationExit(() => { /** * TEMP BLOCK OF CODE, REMOVE AFTER REFACTORING */ -let processingEnabled = true; -let processingState = null; - -/** @param {restWorker.ApplicationContext} appCtx - application context */ -EventListener.initialize = function initialize(appCtx) { - if (appCtx.resourceMonitor) { - if (processingState) { - logger.debug('Destroying existing ProcessingState instance'); - processingState.destroy(); - } - processingState = appCtx.resourceMonitor.initializePState( - onResourceMonitorUpdate.bind(null, true), - onResourceMonitorUpdate.bind(null, false) - ); - processingEnabled = processingState.enabled; - onResourceMonitorUpdate(processingEnabled); - } else { - logger.error('Unable to subscribe to Resource Monitor updates!'); - } +const processingState = { + enabled: true, + promise: Promise.resolve(), + timestamp: hrtimestamp() }; -/** @param {boolean} enabled - true if processing enabled otherwise false */ -function onResourceMonitorUpdate(enabled) { - processingEnabled = enabled; - if (enabled) { - logger.warning('Restriction ceased.'); - EventListener.receiversManager.enableIngress(); - } else { - logger.warning('Applying restrictions to incomming data.'); - EventListener.receiversManager.disableIngress(); - } +/** @param {ApplicationEvents} appEvents - application events */ +EventListener.initialize = function initialize(appEvents) { + appEvents.on('config.change', onConfigChange); + logger.debug('Subscribed to Configuration updates.'); + + appEvents.on('resmon.pstate', (makePState) => makePState( + // on enable + () => updateProcessingState(true), + // on disable + () => updateProcessingState(false) + )); + logger.debug('Subscribed to Resource Monitor updates.'); +}; + +function updateProcessingState(processingEnabledNew) { + const updateTs = hrtimestamp(); + processingState.timestamp = updateTs; + processingState.promise = processingState.promise + .then(() => { + if (processingState.timestamp !== updateTs || processingState.enabled === processingEnabledNew) { + // too late or same state + return; + } + processingState.enabled = processingEnabledNew; + if (processingState.enabled) { + logger.warning('Restriction ceased.'); + EventListener.receiversManager.enableIngress(); + } else { + logger.warning('Applying restrictions to incomming data.'); + EventListener.receiversManager.disableIngress(); + } + }) + .catch((error) => logger.exception(`Unexpected error on attempt to ${processingEnabledNew ? 'enable' : 'disable'} event listeners:`, error)); } /** @@ -477,7 +497,7 @@ function onResourceMonitorUpdate(enabled) { */ EventListener.isEnabled = function isEnabled() { - return processingEnabled; + return processingState.enabled; }; /** diff --git a/src/lib/eventListener/networkService.js b/src/lib/eventListener/networkService.js index 69d65074..94dfa4be 100644 --- a/src/lib/eventListener/networkService.js +++ b/src/lib/eventListener/networkService.js @@ -26,10 +26,18 @@ const logger = require('../logger'); const promiseUtil = require('../utils/promise'); const Service = require('../utils/service'); -/** @module eventListener/networkService */ +/** + * @private + * + * @module eventListener/networkService + */ class SocketServiceError extends Error {} +// TODO: +// - fix address binding +// - allow to specify via declaration + /** * Base Network Service for TCP and UDP protocols * @@ -51,14 +59,15 @@ class BaseNetworkService extends Service { * @param {logger.Logger} [options.logger] - logger to use instead of default one */ constructor(callback, port, options) { - super(); - options = options || {}; + const address = options.address; + + super(options.logger || logger.getChild(`[${address}::${port}]`)); /** define static read-only props that should not be overriden */ Object.defineProperties(this, { address: { - value: options.address + value: address }, callback: { value: callback @@ -67,12 +76,6 @@ class BaseNetworkService extends Service { value: port } }); - Object.defineProperties(this, { - logger: { - value: options.logger || logger.getChild(`${this.constructor.name}::${this.address}::${port}`) - } - }); - this.restartsEnabled = true; } /** @@ -514,6 +517,7 @@ module.exports = { * * @callback ReceiverCallback * @param {ConnInfo} connInfo + * * @returns {MessageStream} */ /** diff --git a/src/lib/eventListener/parser.js b/src/lib/eventListener/parser.js index 187b3167..447d5f5b 100644 --- a/src/lib/eventListener/parser.js +++ b/src/lib/eventListener/parser.js @@ -25,7 +25,11 @@ const constants = require('../constants').EVENT_LISTENER; const hrtimestamp = require('../utils/datetime').hrtimestamp; const CircularArray = require('../utils/structures').CircularArray; -/** @module eventListener/parser */ +/** + * @private + * + * @module eventListener/parser + */ /** * TODO: perf tests @@ -767,7 +771,10 @@ function splitLines(timeLimit, flush) { var parseTime; var state = this._state; var pLeft = state.pLeft; + var pNewLine = state.pNewLine; + var pQuote = state.pQuote; var pRight = state.pRight; + var pValidNewLine = state.pValidNewLine; // do -1 to start time just to show the data was processed (e.g delta will be 1) var startTs = hrtimestamp() - 1; @@ -805,6 +812,10 @@ function splitLines(timeLimit, flush) { this._state.erase(); } else if (pLeft.bufferNo) { pRight.bufferNo -= pLeft.bufferNo; + !pNewLine.isFree && (pNewLine.bufferNo -= pLeft.bufferNo); + !pQuote.isFree && (pQuote.bufferNo -= pLeft.bufferNo); + !pValidNewLine.isFree && (pValidNewLine.bufferNo -= pLeft.bufferNo); + freeNodes.call(this, pLeft.bufferNo); pLeft.bufferNo = 0; } @@ -884,6 +895,10 @@ function freeNodes(nodes) { var i = 0; var payload; + if (nodes >= cArr.length) { + throw new Error(`Number of nodes (${nodes}) to remove is greater than actual size of buffer (${cArr.length})`); + } + while (i++ < nodes) { payload = cArr.pop(); bytes += payload[1]; diff --git a/src/lib/eventListener/stream.js b/src/lib/eventListener/stream.js index 41bf7a36..52f6e5e8 100644 --- a/src/lib/eventListener/stream.js +++ b/src/lib/eventListener/stream.js @@ -24,7 +24,11 @@ const CircularLinkedList = require('../utils/structures').CircularLinkedList; const constants = require('../constants').EVENT_LISTENER; const hrtimestamp = require('../utils/datetime').hrtimestamp; -/** @module eventListener/stream */ +/** + * @private + * + * @module eventListener/stream + */ // keeping a buffer that comes from V8 C/C++ layer for a long time // is too expensive because it cause memory fragmentation. diff --git a/src/lib/eventListener/streamService.js b/src/lib/eventListener/streamService.js index c8aa5ff0..3d9268bd 100644 --- a/src/lib/eventListener/streamService.js +++ b/src/lib/eventListener/streamService.js @@ -19,14 +19,17 @@ /* eslint-disable consistent-return, no-multi-assign, no-plusplus */ const hrtimestamp = require('../utils/datetime').hrtimestamp; -const logger = require('../logger'); const netService = require('./networkService'); const Parser = require('./parser'); const promiseUtil = require('../utils/promise'); const Service = require('../utils/service'); const Stream = require('./stream'); -/** @module eventListener/streamService */ +/** + * @private + * + * @module eventListener/streamService + */ class StreamServiceError extends Error {} @@ -49,11 +52,10 @@ class StreamService extends Service { * @param {boolean} [options.rawDataForwarding = false] - enable raw data forwarding */ constructor(port, options) { - super(); - options = options || {}; + super(options.logger); + this.address = options.address; - this.logger = (options.logger || logger).getChild('messageStream'); this.port = port; this.protocols = options.protocols || ['tcp', 'udp']; diff --git a/src/lib/forwarder.js b/src/lib/forwarder.js deleted file mode 100644 index 292db558..00000000 --- a/src/lib/forwarder.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const actionProcessor = require('./actionProcessor'); -const consumersHndlr = require('./consumers'); -const logger = require('./logger'); // eslint-disable-line no-unused-vars -const promiseUtil = require('./utils/promise'); - -/** -* Forward data to consumer -* -* @param {Object} dataCtx - data context -* @param {Object} dataCtx.data - actual data to forward -* @param {string} dataCtx.type - type of data to forward -* -* @returns {Void} Promise object resolved with undefined -*/ -function forwardData(dataCtx) { - let consumers = consumersHndlr.getConsumers(); - if (!Array.isArray(consumers)) { - return Promise.resolve(); - } - consumers = consumers.filter((c) => dataCtx.destinationIds.indexOf(c.id) > -1); - // don't rely on plugins' code, wrap consumer's call to Promise - // eslint-disable-next-line - return promiseUtil.allSettled(consumers.map((consumer) => { - return new Promise((resolve) => { - // standard context - const context = { - event: consumer.filter.apply(dataCtx), - config: consumer.config, - tracer: consumer.tracer, - logger: logger.getChild(`${consumer.config.type}.${consumer.config.traceName}`), - metadata: consumer.metadata - }; - - try { - // Apply actions to the event - event is already deep copied in consumer.filter.apply() call - actionProcessor.processActions(context.event, consumer.config.actions); - } catch (err) { - // Catch the error, but do not exit - context.logger.exception('Error on attempt to process actions on consumer', err); - } - - // forwarding not guaranteed to succeed, but we will not throw error if attempt failed - try { - consumer.consumer(context); - } catch (err) { - context.logger.exception('Error on attempt to forward data to consumer', err); - } finally { - resolve(); - } - }); - })); -} - -module.exports = { - forward: forwardData -}; diff --git a/src/lib/ihealth.js b/src/lib/ihealth.js deleted file mode 100644 index 1cbd0fb1..00000000 --- a/src/lib/ihealth.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const configUtil = require('./utils/config'); -const configWorker = require('./config'); -const constants = require('./constants'); -const dataPipeline = require('./dataPipeline'); -const errors = require('./errors'); -const iHealthPoller = require('./ihealthPoller'); -const logger = require('./logger').getChild('ihealth'); -const normalize = require('./normalize'); -const promiseUtil = require('./utils/promise'); -const properties = require('./properties.json').ihealth; -const tracerMgr = require('./tracerManager'); -const util = require('./utils/misc'); - -/** @module ihealth */ - -/** - * @this ReportCtx - * @param {QkviewReport} report - * - * @returns {Promise} resolved once F5 iHealth Service Qkview report processed - */ -function reportCallback(report) { - const normalized = normalize.ihealth(report.report, { - filterByKeys: properties.filterKeys, - renameKeys: properties.renameKeys - }); - normalized.system.ihealthLink = report.additionalInfo.ihealthLink; - normalized.system.qkviewNumber = report.additionalInfo.qkviewNumber; - normalized.telemetryServiceInfo = { - cycleStart: new Date(report.additionalInfo.cycleStart).toISOString(), - cycleEnd: new Date(report.additionalInfo.cycleEnd).toISOString() - }; - normalized.telemetryEventCategory = constants.EVENT_TYPES.IHEALTH_POLLER; - const dataCtx = { - data: normalized, - type: constants.EVENT_TYPES.IHEALTH_POLLER, - sourceId: this.pollerID, - destinationIds: this.destinationIDs - }; - return dataPipeline.process(dataCtx, { - noConsumers: this.demoMode, - tracer: this.tracer - }); -} - -/** - * @this function - * @returns {Promise} resolved once finished data processing - */ -function safeProcess() { - try { - // eslint-disable-next-line - return Promise.resolve(this.apply(null, arguments)) - .catch((err) => { - logger.exception('iHealthPoller:safeProcess unhandled exception in promise-chain', err); - }); - } catch (err) { - logger.exception('iHealthPoller:safeProcess unhandled exception', err); - return Promise.resolve(); - } -} - -/** - * @param {IHealthPoller} poller - poller - * @param {object} pollerConfig - iHealth Poller config - * @param {object} globalConfig - entire configuration - * - * @returns {Function} callback for IHealthPoller#report event - */ -function createReportCallback(poller, pollerConfig, globalConfig) { - const ctx = { - destinationIDs: configUtil.getReceivers(globalConfig, pollerConfig).map((r) => r.id), - pollerID: pollerConfig.id, - demoMode: poller.isDemoModeEnabled(), - tracer: tracerMgr.fromConfig(pollerConfig.trace) - }; - return safeProcess.bind(reportCallback.bind(ctx)); -} - -/** - * @param {String} [namespaceName] - Telemetry Namespace name - * - * @returns {Array} array of iHealth Pollers statuses - */ -function getCurrentState(namespaceName) { - let instances = iHealthPoller.getAll({ includeDemo: true }); - - if (instances.length > 0 && namespaceName) { - const ids = configUtil.getTelemetryIHealthPollers(configWorker.currentConfig, namespaceName - || constants.DEFAULT_UNNAMED_NAMESPACE) - .map((pc) => pc.traceName); - instances = instances.filter((poller) => ids.indexOf(poller.id) !== -1); - } - return instances.map((poller) => poller.info()); -} - -/** - * Process client's request via REST API - * - * @property {String} systemName - Telemetry_System name - * @property {String} [namespaceName] - Telemetry_Namespace name - * - * @returns {Promise} resolved with poller's info once started - */ -function startPoller(systemName, namespaceName) { - return Promise.resolve() - .then(() => { - const config = configWorker.currentConfig; - const pollerConfig = configUtil.getTelemetryIHealthPollers(config, namespaceName - || constants.DEFAULT_UNNAMED_NAMESPACE) - .find((pc) => pc.systemName === systemName); - - if (util.isObjectEmpty(pollerConfig)) { - throw new errors.ObjectNotFoundInConfigError('System or iHealth Poller declaration not found'); - } - - let response = { - isRunning: false, - message: `iHealth Poller for System "${systemName}"${namespaceName ? ` (namespace "${namespaceName}")` : ''} started` - }; - let retPromise = Promise.resolve(); - let poller = iHealthPoller.get(pollerConfig.traceName).find((p) => p.isDemoModeEnabled()); - - if (poller) { - response.isRunning = true; - response.message = `${response.message} already`; - response = Object.assign(poller.info(), response); - } else { - poller = iHealthPoller.createDemo(pollerConfig.traceName, { - name: pollerConfig.traceName - }); - poller.on('report', createReportCallback(poller, pollerConfig, config)); - const cleanup = (error) => { - poller.logger.debug(error ? `Done but with error = ${error}` : 'Done'); - // check if poller was disabled already to avoid concurrency with 'config.change' event - if (!poller.isDisabled()) { - iHealthPoller.disable(poller) - .catch((disableError) => poller.logger.debugException('Unexpected error on attempt to disable', disableError)); - } else { - poller.logger.debug('Disabled already!'); - } - }; - poller.on('died', cleanup); - retPromise = retPromise.then(() => poller.start() - .then(() => { - response = Object.assign(poller.info(), response); - }) - .catch((error) => { - response.message = `Unable to start iHealth Poller for System "${systemName}"${namespaceName ? ` (namespace "${namespaceName}")` : ''}: ${error}`; - cleanup(error); - })); - } - return retPromise.then(() => response); - }); -} - -// config worker change event -configWorker.on('change', (config) => Promise.resolve() - .then(() => { - logger.debug('configWorker change event in iHealthPoller'); // helpful debug - const configuredPollers = configUtil.getTelemetryIHealthPollers(config); - - /** - * - if a namespace updated then only pollers that belongs to a namespace will be updated - * - if entire config updated that all pollers will be updated - * - if a namespace updated then only IDs that belongs to a namespace will be regenerated - */ - function cleanupInactive() { - // - stop all removed pollers - doesn't matter even if namespace only was updated - return iHealthPoller.getAll({ includeDemo: true }) - .filter((poller) => !configuredPollers.find((conf) => conf.traceName === poller.id)) - .map((poller) => { - logger.debug(`Removing iHealth Poller "${poller.name}". Reason - removed from configuration.`); - return iHealthPoller.disable(poller); - }); - } - /** - * - do not touch pollers from other namespaces - * - stop disabled pollers - * - stop demo pollers - * - stop active pollers because no info about config changes available - */ - function cleanupUpdated() { - const disablePromises = []; - configuredPollers.forEach((pollerConfig) => { - if (pollerConfig.skipUpdate) { - return; - } - iHealthPoller.get(pollerConfig.traceName).forEach((poller) => { - if (poller) { - if (pollerConfig.enable === false) { - logger.debug(`Removing iHealth Poller "${poller.name}". Reason - disabled.`); - disablePromises.push(iHealthPoller.disable(poller)); - } else { - logger.debug(`Removing iHealth Poller "${poller.name}". Reason - config update.`); - disablePromises.push(iHealthPoller.disable(poller)); - } - } - }); - }); - return disablePromises; - } - - const pollersToStart = []; - // wait for disable only - it is faster rather than wait for complete stop - return promiseUtil.allSettled([ - promiseUtil.allSettled(cleanupInactive()), - promiseUtil.allSettled(cleanupUpdated()) - ]) - .then((results) => { - promiseUtil.getValues(results); // throws error if found it - configuredPollers.forEach((pollerConfig) => { - if (pollerConfig.skipUpdate || pollerConfig.enable === false) { - return; - } - const poller = iHealthPoller.create(pollerConfig.traceName, { name: pollerConfig.traceName }); - poller.on('report', createReportCallback(poller, pollerConfig, config)); - pollersToStart.push(poller); - }); - return promiseUtil.allSettled(pollersToStart.map((poller) => { - logger.info(`Staring iHealth Poller "${poller.name}"`); - return poller.start(); - })); - }) - .then((statuses) => { - statuses.forEach((status, idx) => { - if (status.reason) { - pollersToStart[idx].logger.exception('Error ocurred on attempt to start', status.reason); - } - }); - }); - }) - .then(() => logger.info(`${iHealthPoller.getAll().length} iHealth Poller(s) running`)) - .catch((error) => logger.exception('Uncaught exception on attempt to process iHealth Pollers configuration', error)) - .then(() => iHealthPoller.cleanupOrphanedStorageData()) - .catch((error) => logger.debugException('Uncaught exception on attempt to cleanup orphaned data', error))); - -module.exports = { - getCurrentState, - startPoller -}; - -/** - * @typedef ReportCtx - * @type {Object} - * @property {Array} destinationIDs - destination IDs - * @property {String} pollerID - poller's ID from configuration - * @property {Boolean} demo - in 'demo' mode or not - * @property {Tracer} [tracer] - tracer instance if configured - */ diff --git a/src/lib/ihealth/api/device.js b/src/lib/ihealth/api/device.js new file mode 100644 index 00000000..8f281cf6 --- /dev/null +++ b/src/lib/ihealth/api/device.js @@ -0,0 +1,356 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const path = require('path'); + +const assert = require('../../utils/assert'); +const constants = require('../../constants'); +const dacli = require('../../utils/dacli'); +const defaultLogger = require('../../logger'); +const deviceUtil = require('../../utils/device'); +const util = require('../../utils/misc'); + +/** + * @module ihealth/api/device + * + * @typedef {import('../../logger').Logger} Logger + * @typedef {import('../../utils/config').Connection} Connection + * @typedef {import('../../utils/config').Credentials} Credentials + */ + +/** + * API to interact with a remote BIG-IP + * + * @property {Connection} connection + * @property {Credentials} credentials + * @property {string} host + * @property {Logger} logger + */ +class DeviceAPI { + /** + * Constructor + * + * @param {string} host - host + * @param {object} options - options + * @param {Logger} options.logger - parent logger + * @param {Credentials} [options.credentials] - F5 Device credentials + * @param {null | string} [options.credentials.token] - F5 Device authorization token + * @param {Connection} [options.connection] - F5 Device connection settings + */ + constructor(host, { + connection = undefined, + credentials = undefined, + logger = undefined + } = {}) { + assert.string(host, 'target host'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.bigip.credentials(host, credentials, 'credentials'); + + if (connection) { + assert.bigip.connection(connection, 'connection'); + } + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + host: { + value: host + }, + connection: { + value: util.deepFreeze(util.deepCopy(connection)) + }, + credentials: { + value: util.deepCopy(credentials || {}) + }, + logger: { + value: logger.getChild(`${this.constructor.name}[${host}]`) + } + }); + } + + /** + * Creates Qkview + * + * Note: should be run under a user with sufficient rights/access to avoid Qkview file corruption + * + * @param {string} qkviewName - Qkview name + * @param {string} [dir] - parent directory + * + * @returns {string} absolute path to Qkview file + */ + async createQkview(qkviewName, dir = constants.DEVICE_TMP_DIR) { + assert.string(qkviewName, 'Qkview file name'); + assert.string(dir, 'directory'); + + const qkviewPath = path.join(dir, qkviewName); + this.logger.debug(`Creating Qkview at ${qkviewPath}`); + + await getAuthToken.call(this); + await dacli(buildQkviewCommand(qkviewPath), this.host, getDACLIOptions.call(this, 'qkview')); + + return qkviewPath; + } + + /** + * Downloads file from device + * + * Workflow is following: + * - check if remote path exists + * - create symlink for file in directory designated for downloading files + * to avoid errors like 'not enough space' and etc. + * - check if symlink created + * - download file to local env + * - remove symlink despite on result + * + * @param {string} srcPath - path to file on device + * @param {string} dstPath - path to download file to + * + * @returns {string} absolute path to downloaded file + */ + async downloadFile(srcPath, dstPath) { + assert.string(srcPath, 'source file'); + assert.string(dstPath, 'destination file'); + + this.logger.debug(`Downloading file "${srcPath}" to "${dstPath}"`); + + await getAuthToken.call(this); + await pathExists.call(this, srcPath); // throws error if path does not exist + + const remoteFileName = path.basename(srcPath); + const downloadInfo = getDownloadInfo(this); + const remoteDownloadPath = path.join(downloadInfo.dir, remoteFileName); + const remoteDownloadURI = downloadInfo.uri; + + await createSymLink.call(this, srcPath, remoteDownloadPath); + await pathExists.call(this, remoteDownloadPath); // throws error if path does not exist + + let bytes = 0; + try { + bytes = await deviceUtil.downloadFileFromDevice( + dstPath, + this.host, + path.join(remoteDownloadURI, remoteFileName), + getDefaultRequestOptions.call(this) + ); + } finally { + await this.removeFile(remoteDownloadPath); + } + + this.logger.debug(`Transferred and written ${bytes} bytes to "${dstPath}"`); + + return dstPath; + } + + /** + * Fetches device's info + * + * @returns {object} device's info + */ + async getDeviceInfo() { + await getAuthToken.call(this); + return deviceUtil.getDeviceInfo(this.host, getDefaultRequestOptions.call(this)); + } + + /** + * Calculates MD5 sum for file + * + * Note: creating MD5 sum using DACLI because it might take a while (depends on file size) and + * better to execute such processes in async way to avoid timeout errors + * + * @param {string} filePath - path to file to calculate MD5 for + * + * @returns {string} file's MD5Sum + */ + async getMD5sum(filePath) { + assert.string(filePath, 'path to a file'); + + const md5File = `${filePath}.md5sum`; + const md5Cmd = `md5sum "${filePath}" > "${md5File}"`; + const shellCmd = `cat \\"${md5File}\\"`; + + this.logger.debug(`Calculating MD5 for "${filePath}" ("${md5File}")`); + await getAuthToken.call(this); + + let md5 = ''; + try { + await dacli(md5Cmd, this.host, getDACLIOptions.call(this, 'md5')); + md5 = (await deviceUtil.executeShellCommandOnDevice( + this.host, + shellCmd, + getDefaultRequestOptions.call(this) + )) + .trim() + .split(/\s+/)[0]; + } finally { + await this.removeFile(md5File); + } + + if (!md5) { + throw new Error(`MD5 file "${md5File}" is empty!`); + } + return md5; + } + + /** + * Removes file + * + * @param {string} filePath - path to file to remove + */ + async removeFile(filePath) { + assert.string(filePath, 'path to a file'); + + this.logger.debug(`Removing "${filePath}"`); + + await getAuthToken.call(this); + try { + await deviceUtil.removePath( + filePath, + this.host, + getDefaultRequestOptions.call(this) + ); + } catch (error) { + this.logger.debugException(`Unable to remove "${filePath}"`, error); + } + } +} + +/** + * Builds Qkview command + * + * Note: on BIG-IP v11.6+ qkview utility uses /var/tmp as base dir for output. + * To avoid 'not enough space' error we have to build path using '../../ + * + * @param {string} qkviewFilePath - absolute path to Qkview file + * + * @returns {string} Qkview command + */ +function buildQkviewCommand(qkviewFilePath) { + return `/usr/bin/qkview -C -f ../..${qkviewFilePath}`; +} + +/** + * @this {DeviceAPI} + * + * @returns {Connection | undefined} copied connection options + */ +function cloneConnectionOptions() { + return this.connection ? util.deepCopy(this.connection) : undefined; +} + +/** + * Creates symbolic link for file + * + * @this {DeviceAPI} + * + * @param {string} filePath - path to file + * @param {string} linkPath - path to link + */ +async function createSymLink(filePath, linkPath) { + this.logger.debug(`Creating symbolic link "${linkPath}" for "${filePath}"`); + await deviceUtil.executeShellCommandOnDevice( + this.host, + `ln -s \\"${filePath}\\" \\"${linkPath}\\"`, + getDefaultRequestOptions.call(this) + ); +} + +/** + * Requests auth token + * + * @this {DeviceAPI} + */ +async function getAuthToken() { + if (typeof this.credentials.token !== 'undefined') { + return; + } + // in case of optimization, replace with Object.assign + this.credentials.token = (await deviceUtil.getAuthToken( + this.host, + this.credentials.username, + this.credentials.passphrase, + cloneConnectionOptions.call(this) + )).token; + util.deepFreeze(this.credentials); +} + +/** + * Gets options for DACLI + * + * @this {DeviceAPI} + * + * @param {string} operationName + * + * @returns {object} options + */ +function getDACLIOptions(operationName) { + return { + scriptName: `ts_ihealth_${operationName}_${util.generateUuid().slice(0, 5).replace(/-/g, '_')}`, + connection: cloneConnectionOptions.call(this), + credentials: util.deepCopy(this.credentials) + }; +} + +/** + * Gets default request' options + * + * @this {DeviceAPI} + * + * @returns {object} default request' options + */ +function getDefaultRequestOptions() { + const options = Object.assign({}, cloneConnectionOptions.call(this) || {}); + options.credentials = { token: this.credentials.token }; + + if (this.credentials.username) { + options.credentials.username = this.credentials.username; + } + return options; +} + +/** + * Gets download locations + * + * @returns {object} download info + */ +function getDownloadInfo() { + const conf = constants.DEVICE_REST_API.TRANSFER_FILES.BULK; + return { + dir: conf.DIR, + uri: conf.URI + }; +} + +/** + * Checks if file exists + * + * @this {DeviceAPI} + * + * @param {string} filePath - path to check + * + * @throws {Error} when path does not exist + */ +async function pathExists(filePath) { + this.logger.debug(`Checking if "${filePath}" exists...`); + const files = await deviceUtil.pathExists(filePath, this.host, getDefaultRequestOptions.call(this)); + + if (files.indexOf(filePath) === -1) { + this.logger.debug(`No such file path "${filePath}" found on the device`); + throw new Error(`pathExists: "${filePath}" doesn't exist`); + } +} + +module.exports = DeviceAPI; diff --git a/src/lib/ihealth/api/ihealth.js b/src/lib/ihealth/api/ihealth.js new file mode 100644 index 00000000..de23dc90 --- /dev/null +++ b/src/lib/ihealth/api/ihealth.js @@ -0,0 +1,375 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('../../utils/assert'); +const constants = require('../../constants'); +const defaultLogger = require('../../logger'); +const requestUtil = require('../../utils/requests'); +const util = require('../../utils/misc'); + +/** + * @module ihealth/api/ihealth + * + * @typedef {import('../../logger').Logger} Logger + * @typedef {import('../../utils/config').Credentials} Credentials + * @typedef {import('../../utils/config').Proxy} Proxy + */ + +/** + * F5 iHealth API class + * + * @private + */ +class IHealthAPI { + /** + * Constructor + * + * @param {Credentials} credentials - F5 iHealth Service credentials + * @param {object} options - other options + * @param {Logger} options.logger - parent logger instance + * @param {Proxy} [options.proxy] - proxy settings for F5 iHealth Service connection + */ + constructor(credentials, { + logger = undefined, + proxy = undefined + } = {}) { + assert.ihealth.credentials(credentials, 'credentials'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + + if (typeof proxy === 'object') { + assert.http.proxy(proxy, 'proxy'); + + proxy = util.deepCopy(proxy); + if (typeof proxy.connection.allowSelfSignedCert === 'undefined') { + proxy.connection.allowSelfSignedCert = false; + } + + proxy = util.deepFreeze(proxy); + } else { + proxy = null; + } + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + credentials: { + value: util.deepFreeze(util.deepCopy(credentials)) + }, + logger: { + value: logger.getChild('iHealthAPI') + }, + proxy: { + value: proxy + } + }); + } + + /** + * Authenticates to F5 iHealth Service with provided credentials + * + * @returns {void} once got HTTP 200 OK + */ + async authenticate() { + if (this._token && Date.now() <= this._token.expires_in) { + // still valid token + return; + } + + this._token = null; + + this.logger.debug('Authenticating to F5 iHealth Service'); + + const auth = Buffer.from(`${this.credentials.username}:${this.credentials.passphrase}`).toString('base64'); + const requestOptions = this.getDefaultRequestOptions(); + requestOptions.fullURI = constants.IHEALTH.SERVICE_API.LOGIN; + requestOptions.body = 'grant_type=client_credentials&scope=ihealth'; + requestOptions.json = false; + requestOptions.method = 'POST'; + Object.assign(requestOptions.headers, { + Accept: 'application/json', + Authorization: `Basic ${auth}`, + 'Cache-Control': 'no-cache', + 'Content-Type': 'application/x-www-form-urlencoded' + }); + requestOptions.expectedResponseCode = 200; + + const response = await requestUtil.makeRequest(requestOptions); + assert.ihealth.response.auth(response, 'response'); + + this._token = response; + // adjust by 2 minutes to avoid issues with expiration + this._token.expires_in = Date.now() + (this._token.expires_in - 2 * 60) * 1000; + // freeze to avoid accidental changes + util.deepFreeze(this._token); + } + + /** + * Fetches Qkview diagnostics data from F5 iHealth Service + * + * @property {string} qkviewURI - Qkview diagnostics URI + * + * @returns {object} Qkview diagnostics data + */ + async fetchQkviewDiagnostics(qkviewURI) { + this.logger.debug(`Fetching Qkview diagnostics from "${qkviewURI}"`); + + const requestOptions = this.getDefaultRequestOptions(); + requestOptions.fullURI = qkviewURI; + requestOptions.method = 'GET'; + requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0+json'; + requestOptions.continueOnErrorCode = true; + requestOptions.includeResponseObject = true; + requestOptions.rawResponseBody = true; + + const res = await requestUtil.makeRequest(requestOptions); + const respObj = res[1]; + let body = res[0]; + + this.logger.verbose(`Qkview diagnostics: ${body}`); + + try { + body = JSON.parse(body); + } catch (parseErr) { + throw new Error(`Unable to parse Qkview diagnostics response to F5 iHealth server: responseCode = ${respObj.statusCode} responseBody = ${body}`); + } + + assert.ihealth.response.diagnostics(body, 'response'); + return body; + } + + /** + * Fetches Qkview Report status + * + * @property {string} qkviewURI - Qkview URI (returned after Qkview upload) + * + * @returns {Report} Qkview report + */ + async fetchQkviewReportStatus(qkviewURI) { + await this.authenticate(); + + this.logger.debug(`Fetching Qkview report from "${qkviewURI}"`); + + const requestOptions = this.getDefaultRequestOptions(); + requestOptions.fullURI = qkviewURI; + requestOptions.method = 'GET'; + requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0+json'; + requestOptions.continueOnErrorCode = true; + requestOptions.includeResponseObject = true; + requestOptions.rawResponseBody = true; + + const res = await requestUtil.makeRequest(requestOptions); + const respObj = res[1]; + let body = res[0]; + + this.logger.verbose(`Qkview report: ${body}`); + + try { + body = JSON.parse(body); + } catch (parseErr) { + throw new Error(`Unable to parse Qkview report response to F5 iHealth server: responseCode = ${respObj.statusCode} responseBody = ${body}`); + } + + assert.ihealth.response.report(body, 'report'); + + const ret = { + qkviewURI, + status: { + done: body.processing_status === 'COMPLETE', + error: body.processing_status === 'ERROR' + } + }; + + if (ret.status.error) { + ret.status.done = true; + ret.status.errorMessage = body.processing_messages; + } + if (ret.status.done && !ret.status.error) { + ret.diagnosticsURI = body.diagnostics; + } + + return ret; + } + + /** + * Makes default options for 'request' library + * + * @returns {object} default 'request' options + */ + getDefaultRequestOptions() { + const options = { + headers: { + 'User-Agent': constants.USER_AGENT + }, + allowSelfSignedCert: this.getAllowSelfSignedCertFlag() + }; + + if (this.proxy) { + options.proxy = this.getProxy(); + } + + if (this._token) { + options.headers.Authorization = `Bearer ${this._token.access_token}`; + } + + return options; + } + + /** + * Makes proxy config + * + * @returns {object} proxy config + */ + getProxy() { + const proxy = { + host: this.proxy.connection.host + }; + if (typeof this.proxy.connection.port !== 'undefined') { + proxy.port = this.proxy.connection.port; + } + if (typeof this.proxy.connection.protocol !== 'undefined') { + proxy.protocol = this.proxy.connection.protocol; + } + if (this.proxy.credentials) { + proxy.username = this.proxy.credentials.username; + if (typeof this.proxy.credentials.passphrase !== 'undefined') { + proxy.passphrase = this.proxy.credentials.passphrase; + } + } + return proxy; + } + + /** + * Gets value for strict SSL options + * + * @returns {boolean} + */ + getAllowSelfSignedCertFlag() { + let allowSelfSignedCert = false; // by default, because connecting to F5 API + if (this.proxy && typeof this.proxy.connection.allowSelfSignedCert === 'boolean') { + allowSelfSignedCert = this.proxy.connection.allowSelfSignedCert; + } + return allowSelfSignedCert; + } + + /** + * Uploads Qkview file to F5 iHealth Service + * + * @param {string} qkviewFile - path to Qkview file + * + * @returns {string} URI of the Qkview uploaded to F5 iHealth service + */ + async uploadQkview(qkviewFile) { + await this.authenticate(); + + this.logger.debug(`Uploading Qkview "${qkviewFile}" to F5 iHealth Service`); + + const requestOptions = this.getDefaultRequestOptions(); + requestOptions.fullURI = constants.IHEALTH.SERVICE_API.UPLOAD; + requestOptions.method = 'POST'; + requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0+json'; + requestOptions.formData = { + qkview: util.fs.createReadStream(qkviewFile), + visible_in_gui: 'True' + }; + requestOptions.continueOnErrorCode = true; + requestOptions.includeResponseObject = true; + requestOptions.rawResponseBody = true; + + const res = await requestUtil.makeRequest(requestOptions); + const respObj = res[1]; + let body = res[0]; + + this.logger.verbose(`Qkview uploaded: ${body}`); + + try { + body = JSON.parse(body); + } catch (parseErr) { + throw new Error(`Unable to upload Qkview to F5 iHealth server - unable to parse response body: responseCode = ${respObj.statusCode} responseBody = ${body}`); + } + + assert.ihealth.response.upload(body, 'response'); + this.logger.debug('Qkview uploaded to F5 iHealth service'); + return body.location; + } +} + +/** + * iHealth Manager to upload Qkview and poll diagnostics from the local device + * + * @property {IHealthAPI} api - instance of IHealthAPI + */ +class IHealthManager { + /** + * Constructor + * + * @param {Credentials} credentials - F5 iHealth Service credentials + * @param {object} options - function options + * @param {Logger} options.logger - parent logger + * @param {Proxy} [options.proxy] - proxy settings for F5 iHealth Service connection + */ + constructor(credentials, options) { + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + api: { + value: new IHealthAPI(credentials, options) + } + }); + } + + /** + * Retrieves diagnostics data from F5 iHealth service + * + * @param {string} qkviewURI - Qkview' URI on F5 iHealth + * + * @return {Report} Qkview report + */ + async fetchQkviewDiagnostics(qkviewURI) { + const report = await this.api.fetchQkviewReportStatus(qkviewURI); + if (report.status.done && !report.status.error) { + assert.string(report.diagnosticsURI, 'qkviewURI'); + report.diagnostics = await this.api.fetchQkviewDiagnostics(report.diagnosticsURI); + } + + assert.ihealth.report(report, 'report'); + return report; + } + + /** + * Uploads Qkview to F5 iHealth service + * + * @param {string} qkviewFile - path to Qkview file on the device + * + * @return {string} URI of the Qkview uploaded to F5 iHealth service + */ + async uploadQkview(qkviewFile) { + assert.string(qkviewFile, 'qkviewFile'); + return this.api.uploadQkview(qkviewFile); + } +} + +module.exports = IHealthManager; + +/** + * @typedef {object} Report + * @property {string} diagnosticsURI - iHealth Qkview Diagnostics URI + * @property {object} diagnostics + * @property {string} qkviewURI - iHealth Qkview URI + * @property {object} status - report status + * @property {boolean} status.done - is report done/succeed + * @property {boolean} status.error - is report processing failed + * @property {string} status.errorMessage - error message + */ diff --git a/src/lib/ihealth/api/qkview.js b/src/lib/ihealth/api/qkview.js new file mode 100644 index 00000000..00d4002e --- /dev/null +++ b/src/lib/ihealth/api/qkview.js @@ -0,0 +1,169 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const isEqual = require('lodash/isEqual'); +const pathUtil = require('path'); + +const assert = require('../../utils/assert'); +const DeviceAPI = require('./device'); +const util = require('../../utils/misc'); + +/** + * @module ihealth/api/qkview + */ + +/** + * Qkview Manager + */ +class QkviewManager { + /** + * Constructor + * + * @param {DeviceAPI} local - local device + * @param {DeviceAPI} remote - remote device + * @param {options} options - options + * @param {string} options.downloadFolder - directory for download + */ + constructor(local, remote, { + downloadFolder = undefined + } = {}) { + assert.instanceOf(local, DeviceAPI, 'local device'); + assert.instanceOf(remote, DeviceAPI, 'remote device'); + assert.string(downloadFolder, 'download folder'); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + downloadFolder: { + value: downloadFolder + }, + local: { + value: local + }, + remote: { + value: remote + } + }); + } + + /** + * Generates Qkview file on the deivce and downloading it (if needed) + * + * @returns {string} absolute path to Qkview file on the local device + */ + async generateQkview() { + const localDevice = this.local; + let remoteDevice = this.remote; + + const localInfo = await localDevice.getDeviceInfo(); + const remoteInfo = await remoteDevice.getDeviceInfo(); + + if (isEqual(localInfo, remoteInfo)) { + // remote and local devices are the same + remoteDevice = localDevice; + } + + const remoteQkviewPath = await createQkview.call(this, remoteDevice); + let downloadErr; + let localQkviewPath; + + if (remoteDevice === localDevice) { + localQkviewPath = remoteQkviewPath; + } else { + localQkviewPath = pathUtil.join(this.downloadFolder, pathUtil.basename(remoteQkviewPath)); + try { + await downloadFile( + remoteDevice, + localDevice, + remoteQkviewPath, + localQkviewPath + ); + } catch (error) { + downloadErr = error; + } + } + + if (remoteDevice !== localDevice) { + await remoteDevice.removeFile(remoteQkviewPath); + } + + if (downloadErr) { + await localDevice.removeFile(localQkviewPath); + throw downloadErr; + } + + return localQkviewPath; + } + + /** + * Removes file from the local deivce + * + * @param {string} filePath + * + * @returns {void} once file removed + */ + async removeLocalFile(filePath) { + return this.local.removeFile(filePath); + } +} + +/** + * Creates Qkview via REST API on the deivce + * + * @this QkviewManager + * + * @param {DeviceAPI} device + * + * @returns {string} file name once Qkview file created + */ +async function createQkview(device) { + return device.createQkview(generateQkviewName(), this.downloadFolder); +} + +/** + * Downloads file from the device + * + * @param {DeviceAPI} remote + * @param {DeviceAPI} local + * @param {string} srcPath - path to file on the remote device + * @param {string} dstPath - path to download file to the local device + * + * @returns {string} absolute path to downloaded file on the local device + */ +async function downloadFile(remote, local, srcPath, dstPath) { + await remote.downloadFile(srcPath, dstPath); + const remoteMD5 = await remote.getMD5sum(srcPath); + const localMD5 = await local.getMD5sum(dstPath); + + if (localMD5 !== remoteMD5) { + throw new Error(`MD5 sum "${localMD5}" for the downloaded file "${dstPath}" !== MD5 sum "${remoteMD5}" for the remote file "${srcPath}"`); + } + return dstPath; +} + +/** + * Generates Qkview file name + * + * @returns {string} Qkview file name + */ +function generateQkviewName() { + const currentTime = (new Date()).getTime(); + const hrTime = process.hrtime(); + return `qkview_telemetry_${util.generateUuid().slice(0, 5).replace(/-/g, '_')}_${currentTime}_${hrTime[0]}${hrTime[1]}.tar.qkview`; +} + +module.exports = QkviewManager; diff --git a/src/lib/ihealth/index.js b/src/lib/ihealth/index.js new file mode 100644 index 00000000..3f90fe6e --- /dev/null +++ b/src/lib/ihealth/index.js @@ -0,0 +1,885 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-continue, no-restricted-syntax, no-use-before-define */ + +const assert = require('../utils/assert'); +const constants = require('../constants'); +const errors = require('../errors'); +const Poller = require('./poller'); +const promiseUtil = require('../utils/promise'); +const Service = require('../utils/service'); +const util = require('../utils/misc'); +// TODO: remove once dataPipeline updated +const dataPipeline = require('../dataPipeline'); +const normalize = require('./normalize'); + +const DEMO_PREFIX = 'DEMO_'; +const PRIVATES = new WeakMap(); + +/** + * @module ihealth + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('../utils/config').IHealthPollerCompontent} IHealthPollerCompontent + * @typedef {import('../logger').Logger} Logger + * @typedef {import('./poller').Poller} Poller + * @typedef {import('./poller').PollerInfo} PollerInfo + * @typedef {import('./poller').QkviewReport} QkviewReport + * @typedef {import('../restAPI').Register} RegisterRestApiHandler + * @typedef {import('../restAPI').Config} RestApiConfig + * @typedef {import('../restAPI').RequestHandler} RestApiHandler + * @typedef {import('./poller').StorageState} StorageState + */ + +const EE_NAMESPACE = 'ihealth'; + +/** + * iHealth Service Class + */ +class IHealthService extends Service { + /** @inheritdoc */ + async _onStart() { + /** @type {Map} */ + this._byPoller = new Map(); + + /** @type {Object} */ + this._hash2id = {}; + + /** @type {Object} */ + this._id2hash = {}; + + scheduleDemoPollersCleanup.call(this); + + // start listening for events + this._registerEvents(); + } + + /** @inheritdoc */ + async _onStop() { + // stop receiving config updates + this._configListener.off(); + this._configListener = null; + + // stop receiving REST API updates + this._restApiListener.off(); + this._restApiListener = null; + + if (this._offRestApiHandlers) { + await this._offRestApiHandlers(); + } + + await stopDemoPollersCleanup.call(this); + + if (this._configUpdatePromise) { + this.logger.debug('Waiting for config routine to finish'); + await this._configUpdatePromise; + } + + await destroyAllPollers.call(this); + + this._byPoller = null; + this._dataRouting = null; + this._hash2id = null; + this._id2hash = null; + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + } + + /** @returns {number} number of running DEMO pollers */ + get numberOfDemoPollers() { + let demoPollers = 0; + if (this._byPoller) { + for (const rec of this._byPoller.values()) { + if (rec.poller.isDemo) { + demoPollers += 1; + } + } + } + return demoPollers; + } + + /** @returns {number} number of running pollers */ + get numberOfPollers() { + let pollers = 0; + if (this._byPoller) { + pollers = this._byPoller.size - this.numberOfDemoPollers; + } + return pollers; + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._configListener = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._restApiListener = appEvents.on('restapi.register', onRestApi.bind(this), { objectify: true }); + this.logger.debug('Subscribed to REST API updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'config.applied': 'config.applied' }, + 'config.decrypt', + 'config.getConfig', + 'config.getHash', + 'storage.get', + 'storage.remove', + 'storage.set' + ]); + this.logger.debug('Registered public events.'); + }; + } +} + +/** + * @this IHealthService + * + * @param {IHealthPollerCompontent | IHealthPollerCompontent[]} config + * + * @returns {IHealthPollerCompontent | IHealthPollerCompontent[]} decrypted config + */ +async function decryptConfigs(config) { + return new Promise((resolve, reject) => { + this.ee.emitAsync('config.decrypt', util.deepCopy(config), (error, decrypted) => { + if (error) { + reject(error); + } else { + resolve(decrypted); + } + }); + }); +} + +/** + * @this IHealthService + * + * @param {Record} rec + * + * @returns {void} once deregistered + */ +function deregisterPoller(rec) { + PRIVATES.delete(rec.poller); + this._byPoller.delete(rec.poller); +} + +/** + * @this IHealthService + * + * @returns {void} once all pollers destroyed + */ +async function destroyAllPollers() { + this.logger.info('Destroying all registered pollers'); + + const promises = []; + for (const rec of this._byPoller.values()) { + promises.push(destroyPoller.call(this, rec)); + } + + await promiseUtil.allSettled(promises); +} + +/** + * @this IHealthService + * + * @param {DemoRecord | Record} rec + * + * @returns {void} once poller destroyed + */ +async function destroyPoller(rec) { + try { + await rec.poller.destroy(); + } catch (error) { + this.logger.exception(`Uncaught error on attempt to destroy poller "${rec.name}":`, error); + } + this.logger.debug(`Poller "${rec.name}" destroyed!`); + deregisterPoller.call(this, rec); +} + +/** + * @this IHealthService + * + * @param {object} [options] + * @param {string} [options.hash] - poller's config hash + * @param {string} [options.namespace] - namespace + * + * @returns {IHealthPollerCompontent[]} configs + */ +async function getConfigs({ hash = undefined, namespace = undefined } = {}) { + let id; + if (hash) { + id = this._hash2id[hash]; + assert.string(id, 'id'); + } + + return new Promise((resolve) => { + this.ee.emitAsync('config.getConfig', resolve, { + class: constants.CONFIG_CLASSES.IHEALTH_POLLER_CLASS_NAME, + id, + namespace + }); + }); +} + +/** + * @this IHealthService + * + * @param {IHealthPollerCompontent | IHealthPollerCompontent[]} config + * + * @returns {string | string[]} hashes + */ +async function getHashes(config) { + return new Promise((resolve, reject) => { + this.ee.emitAsync('config.getHash', config, (error, hashes) => { + if (error) { + reject(error); + } else { + resolve(hashes); + } + }); + }); +} + +/** + * @this IHealthService + * + * @property {string} [key] + * + * @returns {any} data from the storage service + */ +async function getStorageData(key) { + const skey = [constants.IHEALTH.STORAGE_KEY]; + if (typeof key !== 'undefined') { + assert.string(key, 'storageKey'); + skey.push(key); + } + return new Promise((resolve, reject) => { + this.ee.emitAsync('storage.get', skey, (error, value) => { + if (error) { + reject(error); + } else { + resolve(value); + } + }); + }); +} + +/** + * @this IHealthService + * + * @param {Record} rec + * + * @returns {void} once registered + */ +function registerPoller(rec) { + this._byPoller.set(rec.poller, rec); +} + +/** + * @this IHealthService + * + * @property {string} key + * + * @returns {void} once data from the storage service removed + */ +async function removeStorageData(key) { + return new Promise((resolve, reject) => { + this.ee.emitAsync('storage.remove', [constants.IHEALTH.STORAGE_KEY, key], (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * @this IHealthService + * + * @returns {void} once DEMO pollers cleanup routine scheduled + */ +function scheduleDemoPollersCleanup() { + const timeout = constants.IHEALTH.DEMO_CLEANUP_TIMEOUT; + + this._demoCleanupTimerID = setTimeout(async () => { + const destroyPromises = []; + const now = Date.now(); + + this._demoCleanupTimerID = null; + + for (const rec of this._byPoller.values()) { + if (!rec.poller.isDemo) { + continue; + } + const pinfo = rec.poller.info(); + if (pinfo.terminated === true && !rec.timestamp) { + rec.timestamp = Date.now(); + continue; + } + if (typeof rec.timestamp !== 'undefined' && (now - rec.timestamp) >= timeout) { + this.logger.debug(`Removing DEMO iHealth Poller "${rec.name}".`); + destroyPromises.push(destroyPoller.call(this, rec)); + } + } + + this._demoCleanupPromise = promiseUtil.allSettled(destroyPromises); + await this._demoCleanupPromise; + this._demoCleanupPromise = null; + + scheduleDemoPollersCleanup.call(this); + // end of the function + }, timeout); +} + +/** + * @this IHealthService + * + * @param {Poller} poller + * @param {QkviewReport} report + * + * @returns {void} once report processed + */ +async function sendQkviewReport(poller, report) { + assert.instanceOf(poller, Poller, 'poller'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + const pollerID = this._hash2id[this._byPoller.get(poller).hash]; + assert.string(pollerID, 'pollerID'); + + let dataCtx = { + data: normalize(report), + sourceId: pollerID, + destinationIds: (!poller.isDemo && this._dataRouting[pollerID]) || [] + }; + dataCtx.type = dataCtx.data.telemetryEventCategory; + + dataCtx = await dataPipeline.process( + dataCtx, + constants.DATA_PIPELINE.PUSH_EVENT, + null + ); + + this.ee.safeEmitAsync('report', dataCtx); +} + +/** + * @this IHealthService + * + * @property {string} key + * @property {any} data + * + * @returns {void} once data from the storage service removed + */ +async function setStorageData(key, data) { + assert.string(key, 'storageKey'); + return new Promise((resolve, reject) => { + this.ee.emitAsync('storage.set', [constants.IHEALTH.STORAGE_KEY, key], data, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * @this IHealthService + * + * @param {IHealthPollerCompontent} config + * @param {string} [hash] - config hash + * @param {boolean} [demo = false] - start in DEMO mode + */ +async function startPoller(config, hash, demo = false) { + assert.safeNumberGr(arguments.length, 1, 'arguments.length'); + assert.string(hash, 'hash'); + assert.boolean(demo, 'demo'); + + const prefix = demo ? DEMO_PREFIX : ''; + + const rec = { + hash, + name: `${prefix}${config.traceName}`, + poller: new Poller(pollerHelpers.buildManagerProxy.call(this), { + demo, + logger: this.logger.getChild(`${prefix}Poller[${config.traceName}]`) + }) + }; + + registerPoller.call(this, rec); + + try { + await rec.poller.start(); + } catch (error) { + deregisterPoller.call(this, rec); + this.logger.exception(`Uncaught error on attempt to start "${rec.name}":`, error); + } +} + +/** + * @this IHealthService + * + * @returns {void} once DEMO pollers cleanup routine stopped + */ +async function stopDemoPollersCleanup() { + if (this._demoCleanupPromise) { + await this._demoCleanupPromise; + } + if (this._demoCleanupTimerID) { + clearTimeout(this._demoCleanupTimerID); + this._demoCleanupTimerID = null; + } +} + +/** + * Apply configuration + * + * @this IHealthService + * + * @param {Configuration} newConfig + * + * @returns {void} once configuration applied + */ +async function onConfigEvent(newConfig) { + const applyConfig = async () => { + this.logger.debug('Config "change" event'); + + // TODO: remove once dataPipeline updated + this._dataRouting = newConfig.mappings; + + // reset mappings + this._hash2id = {}; + this._id2hash = {}; + + const configs = await decryptConfigs.call(this, await getConfigs.call(this)); + configs.forEach((c) => assert.config.ihealthPoller(c, 'ihealthConfig')); + + const hashes = configs.length > 0 ? (await getHashes.call(this, configs)) : []; + hashes.forEach((h) => assert.string(h, 'ihealthConfigHash')); + + const activeHashes = hashes.filter((hash, idx) => configs[idx].enable + && Array.isArray(this._dataRouting[configs[idx].id])); + + const destroyPromises = []; + const runningHashes = []; + + for (const [poller, rec] of this._byPoller) { + if (!poller.isDemo) { + if (activeHashes.includes(rec.hash)) { + runningHashes.push(rec.hash); + } else { + this.logger.debug(`Removing iHealth Poller "${rec.name}". Reason - configuration updated.`); + // do not wait because it may take long time to terminated the process (e.g. qkview download) + destroyPromises.push(destroyPoller.call(this, rec)); + } + } + } + + const startPromises = []; + for (let i = 0; i < hashes.length; i += 1) { + const hash = hashes[i]; + const config = configs[i]; + + this._hash2id[hash] = config.id; + this._id2hash[config.id] = hash; + + if (runningHashes.includes(hash) || !activeHashes.includes(hash)) { + this.logger.debug(`No configuration changes for "${config.traceName}"`); + // poller exists and config didn not changed or poller config is not active + continue; + } + + this.logger.info(`Staring iHealth Poller "${config.traceName}"`); + startPromises.push(startPoller.call(this, config, hash)); + } + promiseUtil.getValues(await promiseUtil.allSettled(startPromises)); + + this.logger.info(`${activeHashes.length} iHealth Poller(s) running`); + this.logger.info(`${this.numberOfDemoPollers} DEMO iHealth Poller(s) registered`); + + promiseUtil.allSettled(destroyPromises) + .then(async () => { + const storageData = await getStorageData.call(this); + if (typeof storageData === 'object' && storageData) { + this.logger.debug('Removing obsolete data from the iHealth storage'); + const storageRemovePromises = []; + for (const key in storageData) { + if (!activeHashes.includes(key)) { + storageRemovePromises.push(removeStorageData.call(this, key)); + } + } + promiseUtil.getValues(await promiseUtil.allSettled(storageRemovePromises)); + this.logger.debug('Removed obsolete data from the iHealth storage'); + } + }); + }; + + try { + this._configUpdatePromise = applyConfig(); + await this._configUpdatePromise; + } catch (error) { + this.logger.exception('Error caught on attempt to apply configuration to iHealth Service:', error); + } finally { + this._configUpdatePromise = null; + // - emit in any case to show we are done with config processing + // - do not wait for results + this.ee.safeEmitAsync('config.applied'); + } +} + +/** + * Apply REST API configuration + * + * @this IHealthService + * + * @param {RegisterRestApiHandler} register - register handler + * @param {RestApiConfig} config - config + */ +async function onRestApi(register, config) { + if (config.debug) { + // previous handler (if registered) destroyed already + const requestHandler = makeRequestHandler.call(this); + + const offs = [ + register(['DELETE', 'GET'], '/ihealthpoller', requestHandler), + register(['DELETE', 'GET', 'POST'], '/ihealthpoller/:system', requestHandler), + register(['DELETE', 'GET'], '/namespace/:namespace/ihealthpoller', requestHandler), + register(['DELETE', 'GET', 'POST'], '/namespace/:namespace/ihealthpoller/:system', requestHandler) + ]; + this._offRestApiHandlers = () => promiseUtil.allSettled(offs.map((off) => off())); + } +} + +const pollerHelpers = { + /** + * @this IHealthService + * + * @returns {ManagerProxy} proxy object + */ + buildManagerProxy() { + const proxy = {}; + Object.defineProperties(proxy, { + cleanupConfig: { + value: pollerHelpers.cleanupConfig.bind(this) + }, + getConfig: { + value: pollerHelpers.getConfig.bind(this) + }, + getStorage: { + value: pollerHelpers.getStorage.bind(this) + }, + qkviewReport: { + value: pollerHelpers.qkviewReport.bind(this) + }, + saveStorage: { + value: pollerHelpers.saveStorage.bind(this) + } + }); + return proxy; + }, + + /** + * @this IHealthService + * + * @param {Poller} poller + * + * @returns {void} once poller's cached config removed + */ + async cleanupConfig(poller) { + // demo pollers allowed too + assert.instanceOf(poller, Poller, 'poller'); + PRIVATES.delete(poller); + }, + + /** + * @this IHealthService + * + * @param {Poller} poller + * @param {boolean} decrypt + * + * @returns {IHealthPollerCompontent} poller's configuration + */ + async getConfig(poller, decrypt) { + assert.instanceOf(poller, Poller, 'poller'); + assert.boolean(decrypt, 'decrypt'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + if (!PRIVATES.has(poller)) { + const config = await getConfigs.call(this, { hash: this._byPoller.get(poller).hash }); + assert.assert(config.length === 1, 'pollerConfig', 'should return a config object by hash'); + + PRIVATES.set(poller, { + config: config[0], + decrypted: false + }); + } + + const config = PRIVATES.get(poller); + if (decrypt && config.decrypted === false) { + config.config = await decryptConfigs.call(this, config.config); + config.decrypted = true; + } + + assert.config.ihealthPoller(config.config, 'ihealthConfig'); + // trust to the app's current state - no need to re-verify hash, ID should be enough + return config.config; + }, + + /** + * @this IHealthService + * + * @param {Poller} poller + * + * @returns {any | null} poller's data from the iHealth storage or null when not found + */ + async getStorage(poller) { + assert.instanceOf(poller, Poller, 'poller'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + if (poller.isDemo) { + return null; + } + + const data = await getStorageData.call(this, this._byPoller.get(poller).hash); + return data || null; + }, + + /** + * @this IHealthService + * + * @param {Poller} poller + * @param {QkviewReport} report + * + * @returns {void} once poller's Report processed + */ + async qkviewReport(poller, report) { + sendQkviewReport.call(this, poller, report) + .catch((error) => poller.logger.exception('Uncaught error on attempt to process Qkvew report:', error)); + }, + + /** + * @this IHealthService + * + * @param {Poller} poller + * @param {any} storageData + * + * @returns {void} once poller's data saved to the iHealth storage + */ + async saveStorage(poller, storageData) { + assert.instanceOf(poller, Poller, 'poller'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + if (poller.isDemo) { + return; + } + + await setStorageData.call(this, this._byPoller.get(poller).hash, storageData); + } +}; + +/** + * @this IHealthService + * + * @returns {RestApiHandler} + */ +function makeRequestHandler() { + const service = this; + /** + * @implements {RestApiHandler} + */ + return Object.freeze({ + /** + * Destroyes all DEMO pollers (within namespace) + * + * Query args: + * - all=true - delete all matching pollers despite namespace (when no namespace set) + */ + async _deleteDemo(req, res) { + const pollers = await this._getPollers(req); + const deletedPollers = []; + const promises = []; + + for (const rec of pollers) { + if (rec.poller.isDemo) { + deletedPollers.push(rec.name); + promises.push(destroyPoller.call(service, rec)); + } + } + + promiseUtil.allSettled(promises); + res.code = 200; + res.contentType = 'application/json'; + res.body = { + code: res.code, + numberOfDeletedDemoPollers: deletedPollers.length, + deletedDemoPollers: deletedPollers + }; + }, + + /** + * @param {object} [options] - options + * @param {string} [options.namespace] - namespace + * @param {string} [options.system] - system's name + * + * @returns {IHealthPollerCompontent[]} pollers + * @throws {ObjectNotFoundInConfigError} error when unable to find config + */ + async _getConfigs({ namespace = undefined, system = undefined }) { + let configs = (await getConfigs.call(service, { namespace })); + + if (system) { + configs = configs.filter( + (c) => c.system.name === system + ); + if (configs.length === 0) { + throw new errors.ObjectNotFoundInConfigError('System or iHealth Poller declaration not found'); + } + } + return configs; + }, + + /** @returns {Array} pollers */ + async _getPollers(req) { + const uriParams = req.getUriParams(); + const queryParams = req.getQueryParams(); + + let pollers = Array.from(service._byPoller.values()); + + let namespace = uriParams.namespace; + if (!namespace && queryParams.all !== 'true') { + namespace = constants.DEFAULT_UNNAMED_NAMESPACE; + } + + if (namespace || uriParams.system) { + const hashes = (await this._getConfigs({ + namespace, + system: uriParams.system + })) + .map((c) => service._id2hash[c.id]) // search for config hash by config ID + .filter((h) => h); // filter empty results + + // filter by config hash - allows to include demo and regular pollers that shares a config + pollers = pollers.filter((rec) => hashes.includes(rec.hash)); + } + + return pollers; + }, + + /** + * Responds to user with states for all DEMO pollers (within namespace) + * + * Query args: + * - demo=true - return demo pollers only + * - all=true - return all matching pollers despite namespace (when no namespace set) + */ + async _getStates(req, res) { + let pollers = await this._getPollers(req); + const numberOfPollersTotal = pollers.length; + const numberOfDemoPollers = pollers.reduce((acc, rec) => acc + (rec.poller.isDemo ? 1 : 0), 0); + + if (req.getQueryParams().demo === 'true') { + pollers = pollers.filter((rec) => rec.poller.isDemo); + } + + res.code = 200; + res.contentType = 'application/json'; + res.body = { + code: res.code, + numberOfPollersTotal, + numberOfPollers: numberOfPollersTotal - numberOfDemoPollers, + numberOfDemoPollers, + states: pollers.map((rec) => Object.assign({ + name: rec.name + }, rec.poller.info())) + }; + }, + + /** Responds to user once demo poller created */ + async _startDemo(req, res) { + const uriParams = req.getUriParams(); + let config = await this._getConfigs({ + namespace: uriParams.namespace || constants.DEFAULT_UNNAMED_NAMESPACE, + system: uriParams.system + }); + assert.assert(config.length === 1, 'config', 'should not have multiple configurations!'); + + config = config[0]; + const hash = service._id2hash[config.id]; + assert.defined(hash, 'hash'); + + let demoRec; + + for (const rec of service._byPoller.values()) { + if (rec.hash === hash && rec.poller.isDemo) { + demoRec = rec; + break; + } + } + + if (!demoRec) { + await startPoller.call(service, config, hash, true); + } + + res.code = demoRec ? 200 : 201; + res.contentType = 'application/json'; + res.body = { + code: res.code, + message: demoRec + ? `DEMO poller "${config.traceName}" exists already. Wait for results or delete it.` + : `DEMO poller "${config.traceName}" created.` + }; + }, + + /** @inheritdoc */ + async handle(req, res) { + if (req.getMethod() === 'GET') { + return this._getStates(req, res); + } + if (req.getMethod() === 'DELETE') { + return this._deleteDemo(req, res); + } + return this._startDemo(req, res); + }, + name: 'iHealth Service' + }); +} + +module.exports = IHealthService; + +/** + * @typedef {object} Record + * @property {string} hash - config hash + * @property {string} name - Poller's name + * @property {Poller} poller - Poller instance + */ +/** + * @typedef {Record} DemoRecord + * @property {number} timestamp - timestamp when DEMO poller was created + */ +/** + * @typedef {object} ManagerProxy + * @property {async function(poller: Poller)} cleanupConfig + * @property {async function(poller: Poller, decrypt: boolean): IHealthPollerCompontent} getConfig + * @property {async function(poller: Poller): StorageState | null} getStorage + * @property {async function(poller: Poller, report: QkviewReport)} qkviewReport + * @property {async function(poller: Poller, storageData: StorageState)} saveStorage + */ diff --git a/src/lib/ihealth/normalize.js b/src/lib/ihealth/normalize.js new file mode 100644 index 00000000..32bd2fbe --- /dev/null +++ b/src/lib/ihealth/normalize.js @@ -0,0 +1,95 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const { deepCopy } = require('../utils/misc'); +const { EVENT_TYPES } = require('../constants'); +const normalizeUtil = require('../utils/normalize'); + +/** + * @module ihealth/normalize + * + * @typedef {import('./poller').QkviewReport} QkviewReport + */ + +const filterKeys = { + exclude: [ + 'name', + 'output', + 'match' + ] +}; +const renameKeys = { + patterns: { + h_importance: { constant: 'importance' }, + h_action: { constant: 'action' }, + h_name: { constant: 'name' }, + h_cve_ids: { constant: 'cveIds' }, + h_header: { constant: 'header' }, + h_summary: { constant: 'summary' } + }, + options: { + exactMatch: true + } +}; + +/** + * Normalize iHealth data + * + * @param {QkviewReport} report - report to normalize + * + * @returns {Object} normalized report + */ +module.exports = function (report) { + const normalized = { + system: { + hostname: report.diagnostics.system_information.hostname + }, + diagnostics: [] + }; + + deepCopy(report.diagnostics.diagnostics.diagnostic).forEach((diagnostic) => { + const ret = normalizeUtil._renameKeys( + normalizeUtil._filterDataByKeys(diagnostic, filterKeys), + renameKeys.patterns, + renameKeys.options + ); + + const reduced = {}; + Object.assign(reduced, ret.run_data); + Object.assign(reduced, ret.results); + Object.assign(reduced, ret.fixedInVersions); + + if (reduced.version && reduced.version.length === 0) { + delete reduced.version; + } + normalized.diagnostics.push(reduced); + }); + + const urlParts = report.metadata.qkviewURI.split('/'); + const qkviewID = urlParts[urlParts.length - 1]; + + normalized.system.ihealthLink = report.metadata.qkviewURI; + normalized.system.qkviewNumber = qkviewID; + normalized.telemetryServiceInfo = { + cycleStart: new Date(report.metadata.cycleStart).toISOString(), + cycleEnd: new Date(report.metadata.cycleEnd).toISOString() + }; + normalized.telemetryEventCategory = EVENT_TYPES.IHEALTH_POLLER; + + return normalized; +}; diff --git a/src/lib/ihealth/poller.js b/src/lib/ihealth/poller.js new file mode 100644 index 00000000..d90b5800 --- /dev/null +++ b/src/lib/ihealth/poller.js @@ -0,0 +1,984 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-cond-assign, no-constant-condition, no-unused-expressions */ + +const assert = require('../utils/assert'); +const constants = require('../constants'); +const datetimeUtil = require('../utils/datetime'); +const defaultLogger = require('../logger'); +const DeviceAPI = require('./api/device'); +const IHealthAPI = require('./api/ihealth'); +const QkviewAPI = require('./api/qkview'); +const Service = require('../utils/service'); +const util = require('../utils/misc'); +const { withResolvers } = require('../utils/promise'); + +/** + * @module ihealth/poller + * + * @typedef {import('./manager').ManagerProxy} ManagerProxy + * @typedef {import('../utils/config').IHealthPollerCompontent} IHealthPollerCompontent + * @typedef {import('../logger').Logger} Logger + * @typedef {import('./api/ihealth').Report} Report + */ + +/** @type {Object} */ +const STATES = { + DONE: { + next: 'SCHEDULE', + onRun() { + this.state.succeed = true; + this.storage.stats.cyclesCompleted += 1; + this.state.errorMsg = ''; + return true; + } + }, + FAILED: { + next: 'SCHEDULE', + async onRun() { + this.state.endTimestamp = Date.now(); + + // remove qkview file if not done yet + await removeQkview.call(this); + + this.state.errorMsg = `${this.mostRecentError}`; + this.logger.exception('iHealth Poller cycle failed due task error:', this.mostRecentError); + + this.mostRecentError = undefined; + + return true; + } + }, + PAST_DUE: { + next: 'FAILED', + onRun() { + throw new Error('Polling execution date expired!'); + } + }, + QKVIEW_GEN: { + next: 'QKVIEW_UPLOAD', + onFailure() { + this.state.retries.qkviewCollect += 1; + this.storage.stats.qkviewCollectRetries += 1; + return makeRetryConfig.call( + this, + this.state.retries.qkviewCollect, + constants.IHEALTH.POLLER_CONF.QKVIEW_COLLECT + ); + }, + onRun() { + return collectQkview.call(this); + }, + onSuccess() { + this.logger.debug('Successfully generated and collected Qkview file'); + this.storage.stats.qkviewsCollected += 1; + } + }, + QKVIEW_REPORT: { + next: 'SEND_REPORT', + onFailure() { + this.state.retries.reportCollect += 1; + this.storage.stats.reportCollectRetries += 1; + return makeRetryConfig.call( + this, + this.state.retries.reportCollect, + constants.IHEALTH.POLLER_CONF.QKVIEW_REPORT + ); + }, + onRun() { + return collectReport.call(this); + }, + onSuccess() { + this.logger.debug('Successfully obtained Qkview report'); + this.storage.stats.reportsCollected += 1; + } + }, + QKVIEW_UPLOAD: { + next: 'QKVIEW_REPORT', + onFailure() { + this.state.retries.qkviewUpload += 1; + this.storage.stats.qkviewUploadRetries += 1; + return makeRetryConfig.call( + this, + this.state.retries.qkviewUpload, + constants.IHEALTH.POLLER_CONF.QKVIEW_UPLOAD + ); + }, + onRun() { + return uploadQkview.call(this); + }, + onSuccess() { + this.logger.debug('Successfully uploaded Qkview file'); + this.storage.stats.qkviewsUploaded += 1; + } + }, + SCHEDULE: { + next: 'WAITING', + onRun() { + return scheduleNextExecution.call(this); + }, + onSuccess() { + this.logger.debug('Successfully scheduled next execution date'); + } + }, + SEND_REPORT: { + next: 'DONE', + async onRun() { + this.state.endTimestamp = Date.now(); + + // remove qkview file if not done yet + await removeQkview.call(this); + + // send the report and remove reference to it + sendReport.call(this, this.state.qkview.report); + delete this.state.qkview.report; + + return true; + }, + onSuccess() { + this.logger.debug('Successfully processed Qkview report'); + } + }, + TERMINATED: { + next: 'FAILED', + onRun() { + throw new Error('Terminated!'); + } + }, + WAITING: { + next: 'QKVIEW_GEN', + onRun() { + return waitTillExecDate.call(this); + }, + onSuccess() { + this.logger.debug('Starting polling cycle'); + this.state.startTimestamp = Date.now(); + } + } +}; + +Object.keys(STATES).forEach((key) => { + STATES[key] = Object.assign(String(key), STATES[key]); +}); + +const STORAGE_VER = '3.0'; + +/** + * iHealth Poller Class + * + * @property {function} info - current info + * @property {boolean} isDemo + */ +class Poller extends Service { + /** + * @param {ManagerProxy} manager + * @param {object} options + * @param {Logger} options.logger - parent logger + * @param {boolean} [options.demo = false] - 'demo' mode (no scheduling) + */ + constructor(manager, { + demo = false, + logger = undefined + }) { + assert.exist(manager, 'manager'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.boolean(demo, 'demo'); + + super(logger); + + // create now and override later to allow to call .info() + let internals = makeInternals.call(this, manager); + let loopError = false; + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + info: { + value: () => getInfo.call(internals) + }, + isDemo: { + value: !!demo + } + }); + + // no restarts in demo-mode + this.restartsEnabled = !this.isDemo; + + /** @inheritdoc */ + this._onStart = async (onFatalError) => { + internals = makeInternals.call(this, manager); + loopError = false; + + // kick-off main activity loop + mainLoop.call(internals) + .catch(async (error) => { + // the loop is dead + internals.terminatedCb(); + + loopError = true; + + if (this.isDemo) { + this.logger.exception('Terminating DEMO poller due uncaught error:', error); + await this.destroy(); + } else { + onFatalError(error); + } + }); + }; + + /** @inheritdoc */ + this._onStop = async () => { + // set flag to let the loop know + internals.terminated = true; + + // interrupt sleep routine + if (internals.sleepPromise) { + internals.sleepPromise.cancel(new Error('terminated')); + } + + if (loopError) { + // the loop is dead already + return; + } + // wait till terminated + await internals.stopPromise; + }; + } +} + +/** + * @this {Internals} + * + * @returns {void} once exited + */ +async function mainLoop() { + await initStorageData.call(this); + restoreState.call(this); + initPollingState.call(this); + + while (true) { + const currentState = this.terminated + ? STATES.TERMINATED + : this.state.lastKnownState; + + if (currentState === STATES.TERMINATED) { + this.logger.debug(`Transitioning from step "${this.state.lastKnownState}" to "${currentState}"`); + } + + let delayBefore = 0; + let doTaskError; + let nextState; + + try { + assert.function(currentState.onRun, `${currentState}.onRun`); + assert.string(currentState.next, `${currentState}.next`); + assert.defined(STATES[currentState.next], 'STATES[currentState.next]'); + + const succeed = await currentState.onRun.call(this); + assert.boolean(succeed, 'onRun'); + + if (succeed === true) { + if (typeof currentState.onSuccess === 'function') { + await currentState.onSuccess.call(this); + } + + nextState = STATES[currentState.next]; + + this.logger.debug(`Successfully completed "${currentState}" step`); + } else { + nextState = currentState; + + let retry; + + if (typeof currentState.onFailure === 'function') { + retry = await currentState.onFailure.call(this); + } else { + retry = makeRetryConfig.call(1, { DELAY: 0, MAX_RETRIES: 1 }); + } + + const msg = `Step "${currentState}" failed! Re-try allowed = ${retry.allowed}. Re-try attemps left ${retry.left} / ${retry.attempt + retry.left}.`; + this.logger.debug(msg); + + if (retry.allowed) { + delayBefore = retry.delay; + } else { + throw new Error(msg); + } + } + } catch (error) { + // error might be related to initialization, not the actual process + doTaskError = error; + } + + this.mostRecentError = doTaskError || undefined; + + if (this.mostRecentError) { + nextState = STATES.FAILED; + + if (currentState === nextState) { + // unable to process error, FAILED.onRun failed + this.logger.exception(`Uncaught error on attept to process "${nextState}" state:`, this.mostRecentError); + nextState = STATES.SCHEDULE; + } + } + + assert.object(nextState, 'nextState'); + + if (nextState === STATES.SCHEDULE) { + // task done + const prevExecDate = this.state.execDate; + addHistoryRecord.call(this); + + if (!this.poller.isDemo) { + removePollingState.call(this); + initPollingState.call(this, prevExecDate); + } else { + this.logger.debug('Terminating DEMO poller'); + nextState = currentState; + this.terminated = true; + } + } + + if (currentState !== nextState) { + this.state.lastKnownState = nextState; + this.logger.debug(`Transitioning from step "${currentState}" to "${nextState}"`); + } + + await saveState.call(this); + + if (this.terminated) { + break; + } + + // sleep before next step if needed + await sleep.call(this, delayBefore); + } + + await cleanupSensitiveData.call(this); + this.terminatedCb(); +} + +/** + * @this {Internals} + * + * @returns {void} once record added + */ +function addHistoryRecord() { + this.storage.history.push(makeHistoryRecord.call(this)); + + if (this.storage.history.length > constants.IHEALTH.MAX_HISTORY_LEN) { + this.storage.history = this.storage.history.slice( + this.storage.history.length - constants.IHEALTH.MAX_HISTORY_LEN + ); + } +} + +/** + * @this {Internals} + * + * @returns {void} once sensitive data removed + */ +async function cleanupSensitiveData() { + await this.manager.cleanupConfig(this.poller); +} + +/** + * @this {Internals} + * + * @returns {boolean} true when the qkview file successfully obtained + */ +async function collectQkview() { + const mgr = await qkviewManager.call(this); + let success = true; + + try { + this.state.qkview.qkviewFile = await mgr.generateQkview(); + } catch (qkviewError) { + success = false; + + this.state.errorMsg = `Unable to obtain qkview file: ${qkviewError.message || qkviewError}`; + this.logger.exception('Unable to obtain qkview file:', qkviewError); + } + + return success; +} + +/** + * @this {Internals} + * + * @returns {boolean} true when the qkview report successfully obtained + */ +async function collectReport() { + const mgr = await ihealthManager.call(this); + let success = true; + + try { + this.state.qkview.report = await mgr.fetchQkviewDiagnostics(this.state.qkview.qkviewURI); + success = this.state.qkview.report.status.done; + } catch (qkviewError) { + success = false; + + this.state.errorMsg = `Unable to obtain qkview report: ${qkviewError.message || qkviewError}`; + this.logger.exception('Unable to obtain qkview report:', qkviewError); + } + if (success && this.state.qkview.report.status.error) { + // qkview processing failed on F5 iHealth Service server, non-recoverable state + throw new Error(`F5 iHealth Service Error: ${this.state.qkview.report.status.errorMessage}`); + } + return success; +} + +/** + * @this {Internals} + * + * @property {boolean} [decrypt = false] + * + * @returns {IHealthPollerCompontent} once the config + */ +async function getConfig(decrypt = false) { + return this.manager.getConfig(this.poller, decrypt); +} + +/** + * @this {Internals} + * + * @returns {PollerInfo} poller's current info data + */ +function getInfo() { + let nextFireDate; + let prevFireDate; + let timeTill; + + if (this.state && this.state.execDate) { + nextFireDate = (new Date(this.state.execDate)).toISOString(); + timeTill = Math.floor(timeUntilNextExecution.call(this) / 1000); + } else { + nextFireDate = 'not set'; + timeTill = 'not available'; + } + if (this.state && this.state.prevExecDate) { + prevFireDate = (new Date(this.state.prevExecDate)).toISOString(); + } else { + prevFireDate = 'not set'; + } + + const ret = { + demoMode: this.poller.isDemo, + nextFireDate, + prevFireDate, + state: util.deepCopy(this.storage), + terminated: this.terminated, + timeUntilNextExecution: timeTill + }; + if (ret.state && ret.state.state) { + ret.state.state.lastKnownState = String(ret.state.state.lastKnownState); + } + return ret; +} + +/** + * @this {Internals} + * + * @returns {IHealthAPI} instance + */ +async function ihealthManager() { + const config = await getConfig.call(this, true); + + // no copies of config needed, it should be copied by the instance upon creation + return new IHealthAPI( + config.iHealth.credentials, + { + logger: this.logger.getChild(config.iHealth.name), + proxy: config.iHealth.proxy + } + ); +} + +/** + * @this {Internals} + * + * @returns {void} once polling cycle state initialized + */ +function initPollingState(prevExecDate = null) { + if (!this.storage.state) { + this.storage.stats.cycles += 1; + this.storage.state = { + cycleNo: this.storage.stats.cycles, + endTimestamp: null, + execDate: null, + lastKnownState: STATES.SCHEDULE, + prevExecDate, + qkview: {}, + retries: { + qkviewCollect: 0, + qkviewUpload: 0, + reportCollect: 0 + }, + startTimestamp: null + }; + } + this.state = this.storage.state; +} + +/** + * @this {Internals} + * + * @returns {void} once storage data initialized and ready + */ +async function initStorageData() { + let storage = await this.manager.getStorage(this.poller); + + if (!(typeof storage === 'object' && storage !== null && storage.version === STORAGE_VER)) { + this.logger.debug('Creating a new storage struct'); + // ignore prev versions, no migration + storage = { + history: [], + state: null, + stats: { + cycles: 0, + cyclesCompleted: 0, + qkviewsCollected: 0, + qkviewCollectRetries: 0, + qkviewsUploaded: 0, + qkviewUploadRetries: 0, + reportsCollected: 0, + reportCollectRetries: 0 + }, + version: STORAGE_VER + }; + } + this.storage = storage; +} + +/** + * @this {Internals} + * + * @returns {PollingHistory} history record + */ +function makeHistoryRecord() { + return { + cycleNo: this.state.cycleNo, + end: this.state.endTimestamp, + endISO: (new Date(this.state.endTimestamp)).toISOString(), + errorMsg: this.state.errorMsg, + schedule: this.state.execDate, + scheduleISO: (new Date(this.state.execDate)).toISOString(), + state: String(this.state.lastKnownState), + start: this.state.startTimestamp, + startISO: (new Date(this.state.startTimestamp)).toISOString() + }; +} + +/** + * @this {Poller} + * + * @param {ManagerProxy} manager + * + * @returns {Internals} + */ +function makeInternals(manager) { + const internals = {}; + const stopPromise = withResolvers(); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(internals, { + logger: { + value: this.logger + }, + manager: { + value: manager + }, + poller: { + value: this + }, + stopPromise: { + value: stopPromise.promise + }, + terminatedCb: { + value: stopPromise.resolve + } + }); + + Object.assign(internals, { + sleepPromise: null, + state: null, + storage: null, + terminated: false + }); + + return internals; +} + +/** + * @this {Poller} + * + * @param {number} attempt - current attempt no. + * @param {object} options - options + * @param {number} options.DELAY - retry delay + * @param {number} optiosn.MAX_RETRIES - max number of attempts to retry + * + * @returns {RetryOptions} retry options + */ +function makeRetryConfig(attempt, { DELAY = 0, MAX_RETRIES = 1 } = {}) { + const conf = { + attempt, + delay: DELAY, + left: MAX_RETRIES - attempt + }; + conf.allowed = conf.left > 0; + + assert.safeNumberGrEq(conf.left, 0, 'retry.left'); + return conf; +} + +/** + * @this {Internals} + * + * @returns {QkviewAPI} instance + */ +async function qkviewManager() { + const config = await getConfig.call(this, true); + + const localDeviceOptions = { + logger: this.logger.getChild('self') + }; + let remoteDevice; + let sameDevice = false; + + if (config.system.connection.host === constants.LOCAL_HOST) { + sameDevice = true; + localDeviceOptions.connection = config.system.connection; + localDeviceOptions.credentials = config.system.credentials; + } + + // no copies of config needed, it should be copied by the instance upon creation + const localDevice = new DeviceAPI(constants.LOCAL_HOST, localDeviceOptions); + if (sameDevice) { + remoteDevice = localDevice; + } else { + // no copies of config needed, it should be copied by the instance upon creation + remoteDevice = new DeviceAPI(config.system.connection.host, { + connection: config.system.connection, + credentials: config.system.credentials, + logger: this.logger.getChild(config.system.name) + }); + } + + return new QkviewAPI(localDevice, remoteDevice, { + downloadFolder: config.iHealth.downloadFolder + }); +} + +/** + * @this {Internals} + * + * @returns {void} once polling state removed + */ +function removePollingState() { + this.state = null; + this.storage.state = null; +} + +/** + * @this {Internals} + * + * @returns {void} once the qkview file removed + */ +async function removeQkview() { + if (!this.state.qkview.qkviewFile) { + return; + } + + const mgr = await qkviewManager.call(this); + + try { + await mgr.removeLocalFile(this.state.qkview.qkviewFile); + } catch (qkviewError) { + this.logger.exception('Unable to remove qkview file:', qkviewError); + } + + delete this.state.qkview.qkviewFile; +} + +/** + * @this {Internals} + * + * @returns {void} once the state restored + */ +function restoreState() { + const state = this.storage.state; + if (typeof state === 'object' && state !== null) { + assert.defined(STATES[state.lastKnownState], 'STATES[state.lastKnownState]'); + state.lastKnownState = STATES[state.lastKnownState]; + + if (state.lastKnownState === STATES.WAITING) { + // need to check if it is too late or not + const timeTill = state.execDate - Date.now(); + if (timeTill < 0 && Math.abs(timeTill) > constants.IHEALTH.POLLER_CONF.SCHEDULING.MAX_PAST_DUE) { + this.logger.debug(`Next execution is past due - ${timeTill} ms`); + state.lastKnownState = STATES.PAST_DUE; + } + } + this.logger.debug(`Restoring poller to the state "${state.lastKnownState}". Cycle #${state.cycleNo}`); + } +} + +/** + * @this {Internals} + * + * @returns {void} once the state saved + */ +async function saveState() { + this.storage.state = this.state; + + const copy = util.deepCopy(this.storage); + if (copy.state) { + // removes all state relaed attributes + copy.state.lastKnownState = String(copy.state.lastKnownState); + } + + await this.manager.saveStorage(this.poller, copy); +} + +/** + * @this {Internals} + * + * + * @returns {boolean} true once scheduled + */ +async function scheduleNextExecution() { + if (this.poller.isDemo) { + this.state.execDate = Date.now(); + } else { + const config = await getConfig.call(this); + const prevExecDate = this.state.prevExecDate ? new Date(this.state.prevExecDate) : null; + + const nextExecDate = datetimeUtil.getNextFireDate( + config.iHealth.interval, + prevExecDate, + prevExecDate === null + ); + + this.state.execDate = nextExecDate.getTime(); + } + this.logger.debug(`Next polling cycle starts on ${(new Date(this.state.execDate)).toISOString()} (in ${Math.floor(timeUntilNextExecution.call(this) / 1000)} s.)`); + + return true; +} + +/** + * @this {Internals} + * + * @property {Report} report + * + * @returns {void} once report send to the manager + */ +function sendReport(report) { + report.metadata = { + cycleStart: this.state.startTimestamp, + cycleEnd: this.state.endTimestamp, + qkviewURI: this.state.qkview.qkviewURI + }; + assert.ihealth.diagnosticsReport(report, 'report'); + this.manager.qkviewReport(this.poller, report); +} + +/** + * @this {Internals} + * + * @param {number} sleepTime - number of ms. to sleep + * + * @returns {boolean} true when the sleep routine succeed and was not interrupted + */ +async function sleep(sleepTime) { + if (sleepTime > constants.IHEALTH.SECRETS_TIMEOUT) { + await cleanupSensitiveData.call(this); + } + if (this.terminated) { + return false; + } + let success = true; + if (sleepTime <= 0) { + return success; + } + + assert.safeNumber(sleepTime, 'sleepTime'); + this.sleepPromise = util.sleep(sleepTime); + + try { + await this.sleepPromise; + } catch (sleepError) { + success = false; + this.logger.debug(`Sleep routine interrupted: ${sleepError.message}`); + } finally { + this.sleepPromise = null; + } + + return success; +} + +/** + * @this {Internals} + * + * @returns {nunmber} number of milliseconds left till next scheduled execution + */ +function timeUntilNextExecution() { + return this.state.execDate - Date.now(); +} + +/** + * @this {Internals} + * + * @returns {boolean} true when the qkview file successfully uploaded + */ +async function uploadQkview() { + const mgr = await ihealthManager.call(this); + let success = true; + + try { + this.state.qkview.qkviewURI = await mgr.uploadQkview(this.state.qkview.qkviewFile); + } catch (qkviewError) { + success = false; + + this.state.errorMsg = `Unable to upload qkview file: ${qkviewError.message || qkviewError}`; + this.logger.exception('Unable to upload qkview file:', qkviewError); + } + + success && (await removeQkview.call(this)); + return success; +} + +/** + * @this {Internals} + * + * @returns {boolean} true when the waiting routine succeed and was not interrupted + */ +async function waitTillExecDate() { + let success = true; + + const ttu = timeUntilNextExecution.call(this); + if (ttu > 0) { + this.logger.verbose(`Going to sleep for ${ttu / 1000}sec.`); + } + + while (success) { + const sleepTime = Math.min( + timeUntilNextExecution.call(this), + constants.IHEALTH.SLEEP_INTERVAL + ); + if (sleepTime <= 0) { + break; + } + success = await sleep.call(this, sleepTime); + } + return !this.terminated && success; +} + +module.exports = Poller; + +/** + * @typedef {String} FSMState + * @property {string} next - next state on success + * @property {function(): RetryOptions} [onFailure] - function to run on failure + * @property {function(): boolean} onRun - function to run, should return true when succeed + * @property {function} [onSuccess] - function to run on success + */ +/** + * @typedef {object} Internals + * @property {Logger} logger - logger + * @property {ManagerProxy} manager - proxy manager + * @property {Poller} poller - Poller instance + * @property {null | Promise} sleepPromise - sleep promise object or null + * @property {PollingState} state - current polling state, ref. to `StorageState.state` + * @property {Promise} stopPromise - stop promise, resolved once main polling loop stopped + * @property {StorageState} storage - storage data + * @property {boolean} terminated - true if instance was terminated + * @property {function} terminatedCb - function to call once loop terminated (upon request) + */ +/** + * @typedef {object} PollerInfo + * @property {boolean} demoMode + * @property {string} nextFireDate + * @property {string} prevFireDate + * @property {StorageState} state + * @property {boolean} terminated + * @property {number} timeUntilNextExecution + */ +/** + * @typedef {object} PollingHistory + * @property {number} cycleNo - cycle number + * @property {number} end - end date timestamp + * @property {string} endISO - end date in ISO format + * @property {string} errorMsg - when state is FAILED + * @property {number} schedule - origin exec date + * @property {number} scheduleISO - origin exec date in ISO format + * @property {string} state - state + * @property {number} start - start date timestamp + * @property {string} startISO - start date in ISO format + */ +/** + * @typedef {object} PollingState + * @property {number} cycleNo - iteration number + * @property {number} endTimestamp - timestamp of when polling cycle finished + * @property {string} errorMsg - error message if poll cycle failed + * @property {number} execDate - execution date + * @property {string} lastKnownState - state set before data was saved + * @property {number} prevExecDate - previous execution date + * @property {object} qkview - Qkview related data + * @property {string} qkview.qkviewFile - file path to Qkview obtained from BIG-IP + * @property {string} qkview.qkviewURI - Qkview URI returned from F5 iHealth Service + * @property {Report} qkview.report - Qkview report returned from F5 iHealth Service + * @property {boolean} qkview.reportProcessed - whether or not Qkview report was processed + * @property {object} retries - iHealth Poller retries info for current polling cycle + * @property {number} retries.qkviewCollect - number of attempts made to obtain Qkview file + * @property {number} retries.qkviewUpload - number of attempts made to upload Qkview file + * @property {number} retries.reportCollect - number of attempts made to obtain Qkview report + * @property {number} startTimestamp - timestamp of when polling cycle started + * @property {boolean} succeed - whether or not poll cycle completed successfully + */ +/** + * @typedef {object} PollingStats + * @property {number} cycles - number of polling cycles + * @property {number} cyclesCompleted - number of completed cycles + * @property {number} qkviewsCollected - number of Qkview files successfully collected + * @property {number} qkviewCollectRetries - number of attempts made to obtain Qkview file + * @property {number} qkviewsUploaded - number of Qkview files successfully uploaded + * @property {number} qkviewUploadRetries - number of attempts made to upload Qkview file + * @property {number} reportsCollected - number of Qkview reports successfully received + * @property {number} reportCollectRetries - number of attempts made to obtain Qkview report + */ +/** + * @typedef {Report} QkviewReport + * @property {object} metadata + * @property {number} cycleStart - polling cycle start timestamp + * @property {number} cycleEnd - polling cycle end timestamp + */ +/** + * @typedef {object} RetryOptions + * @property {boolean} allowed - is retry allowed or not + * @property {number} attempt - current attempt no. + * @property {number} delay - delay before next attempt + * @property {number} left - attempts left + */ +/** + * @typedef {object} StorageState + * @property {PollingHistory[]} history - last 20 polling cycles history + * @property {PollingState} state - data related to current polling cycle + * @property {PollingStats} stats - iHealth Poller stats + * @property {string} version - data's format version, latest is 3.0 + */ diff --git a/src/lib/ihealthPoller.js b/src/lib/ihealthPoller.js deleted file mode 100644 index 9eb6ceac..00000000 --- a/src/lib/ihealthPoller.js +++ /dev/null @@ -1,1445 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const machina = require('machina'); - -const configUtil = require('./utils/config'); -const configWorker = require('./config'); -const constants = require('./constants'); -const datetimeUtil = require('./utils/datetime'); -const ihealthUtil = require('./utils/ihealth'); -const logger = require('./logger'); -const persistentStorage = require('./persistentStorage').persistentStorage; -const promiseUtil = require('./utils/promise'); -const SafeEventEmitter = require('./utils/eventEmitter'); -const util = require('./utils/misc'); - -/** @module iHealthPoller */ - -/** - * FSM State Transitions - * - * Initial state: uninitialized - * - * uninitialized -----> input=start ----> initialized (emits 'started' event) - * +--> prepare - * initialized ----| - * +--> restore - * prepare -----------> schedule - * schedule ----------> scheduleCheck - * +--> sleep ----> scheduleCheck - * scheduleCheck --| - * +--> collectQkview - * +--> sleep ----> collectQkview - * collectQkview --| - * +--> uploadQkview - * +--> sleep ----> uploadQkview - * uploadQkview --| - * +--> collectReport - * +--> sleep ----> collectReport - * collectReport --| - * +--> processReport (emits 'report' event) - * processReport -----> cleanup ----> completed (emits 'completed' event) - * +--> prepare - * completed ------| - * +--> died (emits 'died' event, without additional arguments) - * died --------------> uninitialized - * +--> scheduleCheck - * +--> collectQkview - * restore --------| - * +--> uploadQkview - * +--> cleanup - * - * All states (except 'cleanup', 'died', 'failed') may transit to 'failed' or 'disabled' states - * - * disabled ----------> cleanup ------> died (emits 'died' event, may pass Error as an argument) - * failed ---------+--> cleanup ------> died (emits 'died' event with Error as an argument) - * +--> died (emits 'died' event with Error as an argument) - */ - -/** - * FSM for iHealth Poller - * - * @class - * - * @fires IHealthPollerFSM.safeEmitter#completed - * @fires IHealthPollerFSM.safeEmitter#died - * @fires IHealthPollerFSM.safeEmitter#disabling - * @fires IHealthPollerFSM.safeEmitter#report - * @fires IHealthPollerFSM.safeEmitter#started - * @fires IHealthPollerFSM.safeEmitter#transitioned - * - * @property {Logger} logger - logger instance - * @property {IHealthPoller} poller - iHealth Poller - * @property {SafeEventEmitter} safeEmitter - event emitter - * @property {iHealthPollerStorage} data - data from PersistentStorage - */ -const IHealthPollerFSM = machina.Fsm.extend({ - namespace: 'ihealth-poller', - initialState: 'uninitialized', - - /** - * Constructor - * @param {object} options - init options - * @param {IHealthPoller} options.poller - IHealthPoller instance - */ - initialize: function initialize(options) { - this.poller = options.poller; - this.logger = this.poller.logger; - this.safeEmitter = new SafeEventEmitter(); - - this.on('transitioned', (args) => { - this.logger.debug(`State changed from "${args.fromState}" to "${args.toState}"`); - this.safeEmitter.safeEmit('transitioned', { fromState: args.fromState, toState: args.toState }); - if (this.states[args.fromState] && this.states[args.fromState].saveOnExit) { - this.data.lastKnownState = args.fromState; - fsmRun.call(this, () => saveDataToStorage.call(this.poller, this.data), { resolve: true }); - } - }); - }, - - /** - * @public - * @returns {void} once cleaned up (should be used only when instance is not needed any more) - */ - cleanup: function cleanup() { - this.data = null; - this.off(); - if (this.safeEmitter) { - this.safeEmitter.stopListeningTo(); - this.safeEmitter.removeAllListeners(); - delete this.safeEmitter; - } - }, - - /** - * @public - * @returns {iHealthPollerFsmInfo} FSM stats - */ - info: function info() { - const nextExecDate = getNextExecDate.call(this); - const timeUntilNext = timeUntilNextExecution.call(this); - return { - currentCycle: util.deepCopy(this.data.currentCycle), - demoMode: this.isDemoModeEnabled(), - disabled: this.isDisabled(), - nextFireDate: nextExecDate === null ? 'not set' : nextExecDate.toISOString(), - state: this.state, - stats: util.deepCopy(this.data.stats), - timeUntilNextExecution: timeUntilNext === null ? 'not available' : timeUntilNext - }; - }, - - /** - * @public - * @returns {boolean} true when state is other than 'uninitialized' - */ - isActive: function isActive() { - // 'died' can walk too! - return this.state !== 'uninitialized'; - }, - - /** - * @public - * @returns {boolean} true when in 'demo' mode - */ - isDemoModeEnabled: function isDemoModeEnabled() { - return this.poller.isDemoModeEnabled(); - }, - - /** - * @public - * @returns {boolean} true when instance disabled - */ - isDisabled: function isDisabled() { - return this.poller.isDisabled(); - }, - - /** - * @public - * @returns {Promise} resolved once FSM started - */ - start: function start() { - return Promise.resolve() - .then(() => { - if (this.isActive()) { - return Promise.reject(new Error('IHealthPollerFSM instance is active already')); - } - const diedPromise = this.safeEmitter.waitFor('died'); - const startedPromise = this.safeEmitter.waitFor('started'); - return promiseUtil.allSettled([ - // wrap 'start' in promise to catch all errors and cancel pending promises - Promise.resolve() - .then(() => this.handle('start')) - .catch((error) => { - diedPromise.cancel(); - startedPromise.cancel(); - return Promise.reject(error); - }), - startedPromise.then(() => diedPromise.cancel()), - diedPromise.then((error) => { - startedPromise.cancel(); - return error; - }) - ]); - }) - .then((statuses) => { - // statuses are in the same order as promises above - if (statuses[0].status === 'fulfilled' && statuses[1].status === 'fulfilled') { - return Promise.resolve(); - } - // 'startedPromise' has no valid rejection reason besides 'cancel' - not interested - return Promise.reject(statuses[0].reason - || statuses[2].reason - || new Error('Unable to start IHealthPollerFSM instance due unknown reason')); - }); - }, - - /** - * @public - * @returns {Promise} resolved once FSM changed it's state to 'died' - */ - stop: function stop() { - return Promise.resolve() - .then(() => { - if (!this._diedPromise) { - this._diedPromise = this.safeEmitter.waitFor('died'); - // catch and resolve errors in case of cancellation - this._diedPromise.catch((error) => error); - } - // need to emit 'disabling' event to - // - interrupt 'sleep' - // - let others know that disabling started - return this.safeEmitter.safeEmitAsync('disabling'); - }) - .then(() => { - if (!this.isActive()) { - if (this._diedPromise) { - // it might be fulfilled already - // but worth to try to cancel it - this._diedPromise.cancel(new Error('IHealthPollerFSM instance is not active')); - } - return Promise.resolve(); - } - return this._diedPromise; - }) - .catch((error) => { - if (this._diedPromise) { - this._diedPromise.cancel(error); - } - return error; - }) - .then((error) => { - delete this._diedPromise; - // waitFor may be resolved with Array (see official docs) - return Array.isArray(error) ? error[0] : error; - }); - }, - - /** - * States - * - * - saveOnExit - when set to 'true' then data will be saved to PersistentStorage once state changed - */ - states: { - /** - * Do cleanup, doesn't matter what state was before - * - * Next state on success - depends on input ('completed' by default, to keep the loop running on) - */ - cleanup: { - saveOnExit: true, - /** - * @param {string} [nextState = 'completed'] - next state once cleanup done - * @param {...Any} [otherArgs] - other args to pass to next state - */ - _onEnter: function _onEnter(nextState) { - nextState = nextState || 'completed'; - // wrapping entire promise chain into _runSafe - fsmRun.call(this, () => promiseUtil.allSettled([ - removeQkview.call(this) // remove Qkview - ]) - .then(() => this.transition.apply(this, [nextState].concat(Array.from(arguments).slice(1))))); - } - }, - /** - * Collect Qkview data from target device - * - * Next state on success - uploadQkview - */ - collectQkview: { - saveOnExit: true, - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRunWithRetry.call(this, () => collectQkview.call(this) - .then(() => this.transition('uploadQkview'))); - } - }, - /** - * Collect Qkview report from F5 iHealth Service - * - * Next state on success - processReport - */ - collectReport: { - saveOnExit: true, - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRunWithRetry.call(this, () => fetchQkviewReport.call(this) - .then((report) => { - if (report.isReady) { - this.transition('processReport', report.report); - } else { - this.logger.debug('Qkview report is not ready yet'); - fsmRetryState.call(this); - } - })); - } - }, - /** - * Polling cycle successfully completed - * - * Next state on success: - * - died - when in 'demo' mode - * - prepare - * - * @fires IHealthPollerFSM.safeEmitter#completed - */ - completed: { - /** - * @fires IHealthPollerFSM.safeEmitter#completed - */ - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - /** - * Emit 'completed' event - * - * @event IHealthPollerFSM.safeEmitter#completed - */ - fsmRun.call(this, () => Promise.resolve() - .then(() => { - this.data.currentCycle.succeed = true; - this.data.stats.cyclesCompleted += 1; - return this.safeEmitter.safeEmitAsync('completed'); - }) - .then(() => this.transition(this.isDemoModeEnabled() ? 'died' : 'prepare'))); - } - }, - /** - * Poller died, final state - * - * @fires IHealthPollerFSM.safeEmitter#died - */ - died: { - /** - * @param {Error} [error] - error to pass to listeners - * @fires IHealthPollerFSM#died - */ - _onEnter: function _onEnter(error) { - /** - * Emit 'died' event - * - * @event IHealthPollerFSM.safeEmitter#died - * @param {Error} [error] - error if exists - */ - fsmRun(() => { - cleanupSensitiveData.call(this); - this.transition('uninitialized'); - return this.safeEmitter.safeEmitAsync('died', error); - }, { resolve: true }); - } - }, - /** - * Poller was disabled via 'stop' - * - * Next state on success - cleanup -> died - */ - disabled: { - _onEnter: function _onEnter() { - fsmRun.call(this, () => this.transition('cleanup', 'died')); - } - }, - /** - * Polling cycle failed - * - * Next state on success: - * - died - when in 'demo' mode - * - prepare - */ - failed: { - /** - * @param {Error} error - fail reason - */ - _onEnter: function _onEnter(error) { - fsmRun.call(this, () => { - this.data.currentCycle.succeed = false; - this.data.currentCycle.errorMsg = `${error}`; - }, { reject: true }) - .catch((innerError) => innerError) - .then((innerError) => { - if (this.isDemoModeEnabled() || this.isDisabled()) { - this.transition('died', innerError || error); - } else { - this.logger.exception('Recovering after receiving error', error); - this.transition('prepare'); - } - }); - } - }, - /** - * Initialize newly created instance - * - * Next state on success - 'prepare' or 'restore' - */ - initialized: { - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => initStorageData.call(this) - .then(() => this.safeEmitter.safeEmitAsync('started')) - .then(() => this.transition(this.data.lastKnownState ? 'restore' : 'prepare'))); - } - }, - /** - * Prepare to next polling cycle - * - * Next state on success - schedule - */ - prepare: { - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => { - this.data.stats.cycles += 1; - initPollingCycleInfo.call(this, this.data); - this.transition('schedule'); - }); - } - }, - /** - * Process Qkview report - * - * Next state on success - cleanup -> completed - * - * @fires IHealthPollerFSM#report - */ - processReport: { - saveOnExit: true, - _onEnter: function _onEnter(report) { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - /** - * Emit 'report' event - * - * @event IHealthPollerFSM#report - * @param {QkviewReport} report - Qkview report - */ - fsmRun.call(this, () => this.safeEmitter.safeEmitAsync('report', processQkviewReport.call(this, report)) - .then(() => { - this.data.currentCycle.qkview.reportProcessed = true; - this.transition('cleanup', 'completed'); - })); - } - }, - /** - * Restore FSM to prev. state if possible - */ - restore: { - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => { - const lastKnownState = this.data.lastKnownState; - const qkview = (this.data.currentCycle && this.data.currentCycle.qkview) || {}; - let nextState; - - this.logger.debug(`Restoring iHealthPollerFSM, last known state is "${lastKnownState}"`); - if (lastKnownState === 'schedule') { - // might be negative in case if past due already - const nextExecTime = timeUntilNextExecution.call(this); - nextState = 'schedule'; - if (nextExecTime >= 0 - || Math.abs(nextExecTime) < constants.IHEALTH.POLLER_CONF.SCHEDULING.MAX_PAST_DUE) { - nextState = 'scheduleCheck'; - } else { - this.logger.debug('Need to schedule new execution date, current one is expired'); - } - } else if (lastKnownState === 'collectQkview') { - nextState = 'collectQkview'; - if (qkview.qkviewFile) { - nextState = 'uploadQkview'; - } - } else if (lastKnownState === 'uploadQkview') { - nextState = 'uploadQkview'; - if (qkview.qkviewURI) { - nextState = 'collectReport'; - } - } else if (lastKnownState === 'collectReport' - || (lastKnownState === 'processReport' && !qkview.reportProcessed)) { - nextState = 'collectReport'; - } - if (!nextState) { - nextState = 'cleanup'; - } - this.logger.debug(`Restoring iHealthPollerFSM to state "${nextState}"`); - this.transition(nextState); - }); - } - }, - /** - * Schedule next polling cycle - * - * Next state on success - scheduleCheck - */ - schedule: { - saveOnExit: true, - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => scheduleNextExecution.call(this) - .then(() => this.transition('scheduleCheck'))); - } - }, - /** - * Check if it is time to start polling cycle - * - * Next state on success: - * - sleep -> scheduleCheck - when it is too early - * - collectQkview - when it is time to start polling cycle - */ - scheduleCheck: { - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => checkNextExecutionTime.call(this) - .then((allowedToStart) => this.handle(allowedToStart ? 'ready' : 'sleep'))); - }, - ready: 'collectQkview', - sleep: function sleep() { - const sleepTime = timeUntilNextExecution.call(this); - const defaultDelay = constants.IHEALTH.POLLER_CONF.SCHEDULING.DELAY; - this.transition('sleep', { - nextState: this.state, - sleepTime: sleepTime < defaultDelay ? sleepTime : defaultDelay - }); - } - }, - /** - * Sleep and nothing else - * - * Next state on success - depends on input - */ - sleep: { - _onEnter: function _onEnter(options) { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - const sleepTime = (options.sleepTime > 0 && options.sleepTime) || 0; - if (sleepTime) { - cleanupSensitiveData.call(this); - } - this.logger.debug(`Going to sleep for ${sleepTime} ms. Next state is "${options.nextState}"`); - - const sleepPromise = util.sleep(sleepTime || 0); - const disablingPromise = this.safeEmitter.waitFor('disabling'); - - promiseUtil.allSettled([ - sleepPromise.then(() => { - disablingPromise.cancel(); - this.transition(options.nextState); - }), - disablingPromise.then(() => { - sleepPromise.cancel(); - this.transition('disabled'); - }) - ]); - } - }, - /** - * Initial state when instance created - * - * Next state on success - initialized - */ - uninitialized: { - start: function start() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRun.call(this, () => this.transition('initialized')); - } - }, - /** - * Upload Qkview to F5 iHealth Service - * - * Next state on success - collectReport - */ - uploadQkview: { - saveOnExit: true, - _onEnter: function _onEnter() { - if (fsmDisabledIfNeeded.call(this)) { - return; - } - fsmRunWithRetry.call(this, () => uploadQkview.call(this) - .then(() => this.transition('collectReport'))); - } - } - } -}); - -Object.defineProperty(IHealthPollerFSM.prototype, 'data', { - get() { - if (!this._data) { - this._data = createStorageObject.call(this); - } - return this._data; - }, - - set(val) { - this._data = val; - } -}); - -/** - * iHealth poller - * - * Note: when 'demo' set to 'true' then following operations disabled/changed the behavior to speed up - * the process: - * - saving current state to storage - disabled - * - scheduling a next operation - delay decreased to speed up the process - * - only 1 polling cycle - * - starts immediately - * - * DEMO instances are useful in case when the user needs to check - * - iHealth credentials/connectivity - * - proxy configuration/credentials/connectivity - * - BIG-IP configuration/credentials/connectivity - * - * @fires IHealthPoller#completed - * @fires IHealthPoller#died - * @fires IHealthPoller#disabled - * @fires IHealthPoller#report - * @fires IHealthPoller#started - * @fires IHealthPoller#transitioned - * - * @property {string} id - iHealth Poller config ID - * @property {Logger} logger - logger instance - * @property {string} name - instance name - * @property {string} storageKey - storage key - */ -class IHealthPoller extends SafeEventEmitter { - /** - * @param {string} id - config object ID - * @param {IHealthPollerOptions} [options] - options - */ - constructor(id, options) { - super(); - options = util.assignDefaults(options, { - demo: false, - name: `${this.constructor.name}_${id}` - }); - - this._demo = !!options.demo; - this._id = id; - this._name = `${options.name}${this._demo ? ' (DEMO)' : ''}`; - this.logger = logger.getChild(this.constructor.name).getChild(this._name); - - this._fsm = new IHealthPollerFSM({ poller: this }); - // passthrough FSM events - this.listenTo(this._fsm.safeEmitter, { - completed: 'completed', - died: 'died', - disabling: 'disabling', - report: 'report', - started: 'started', - transitioned: 'transitioned' - }); - } - - /** - * @public - * @returns {Promise} resolved once 'disabling' event received - */ - get disablingPromise() { - return this._disablingPromise; - } - - /** - * @public - * @returns {string} poller's config ID - */ - get id() { - return this._id; - } - - /** - * @public - * @returns {string} poller's name - */ - get name() { - return this._name; - } - - /** - * @public - * @returns {string} key to use to store data in storage - */ - get storageKey() { - return this.id; - } - - /** - * @public - * Do cleanup once instance died or deleted, should be performed when FSM not running only - */ - cleanup() { - this.stopListeningTo(); - this.removeAllListeners(); - if (this._fsm) { - this._fsm.cleanup(); - delete this._fsm; - } - } - - /** - * @public - * @returns {Promise} resolved with config - */ - getConfig() { - return Promise.resolve() - .then(() => { - const pollerConfig = configUtil.getTelemetryIHealthPollers(configWorker.currentConfig) - .find((ihpConf) => ihpConf.traceName === this.id); - - if (util.isObjectEmpty(pollerConfig)) { - return Promise.reject(new Error(`Configuration for iHealth Poller "${this.name}" (${this.id}) not found!`)); - } - return pollerConfig; - }); - } - - /** - * @public - * @returns {iHealthPollerInfo} poller's info - */ - info() { - return Object.assign(this._fsm.info(), { - id: this.id, - name: this.name - }); - } - - /** - * @public - * @returns {boolean} true when instance is running (even when disabled) - */ - isActive() { - return this._fsm.isActive(); - } - - /** - * @public - * @returns {boolean} 'true' if instance in 'demo' mode - */ - isDemoModeEnabled() { - return this._demo; - } - - /** - * @public - * @returns {boolean} 'true' if instance disabled - */ - isDisabled() { - return !!this._stopPromise; - } - - /** - * @public - * @returns {Promise} resolved once iHealth Poller started - */ - start() { - // simple guardian in case of attempt to start FSM twice - if (!this._startPromise) { - this._startPromise = Promise.resolve() - .then(() => this._fsm.start()) - .catch((error) => error) - .then((error) => { - delete this._startPromise; - return error ? Promise.reject(error) : Promise.resolve(); - }); - } - return this._startPromise; - } - - /** - * May take some time to complete, depends on FSM' current state. - * - * @public - * @returns {Promise} resolved once iHealth Poller stopped - */ - stop() { - // simple guardian in case of attempt to stop FSM twice - if (!this._stopPromise) { - this._disablingPromise = this.waitFor('disabling'); - this._stopPromise = Promise.resolve() - .then(() => removeDataFromStorage.call(this)) - .then(() => this._fsm.stop()) - .catch((error) => { - this._disablingPromise.cancel(error); - return error; - }) - .then((error) => { - delete this._disablingPromise; - delete this._stopPromise; - return error ? Promise.reject(error) : Promise.resolve(); - }); - } - return this._stopPromise; - } -} - -/** - * CLASS METHODS - */ -/** - * @private - * @member {Array} - instances - */ -IHealthPoller._instances = []; - -/** - * @returns {Promise} resolved once all orphaned data removed from storage - */ -IHealthPoller.cleanupOrphanedStorageData = function cleanupOrphanedStorageData() { - return persistentStorage.get(constants.IHEALTH.STORAGE_KEY) - .then((storageData) => { - storageData = storageData || {}; - // ignoring 'demo' instances - they are restricted from storing the data - const existingKeys = IHealthPoller.getAll().map((poller) => poller.storageKey); - Object.keys(storageData).forEach((skey) => { - if (existingKeys.indexOf(skey) === -1) { - delete storageData[skey]; - } - }); - return persistentStorage.set(constants.IHEALTH.STORAGE_KEY, storageData); - }); -}; - -/** - * @param {string} id - poller's config ID - * @param {IHealthPollerOptions} [options] - options, see IHealthPoller.constructor for more details - * - * @returns {IHealthPoller} newly created iHealth Poller instance - */ -IHealthPoller.create = function create(id, options) { - options = options || {}; - let poller = IHealthPoller._instances.find((p) => p.id === id); - - if (poller && poller.isDemoModeEnabled() === !!options.demo) { - throw new Error(`iHealthPoller instance with ID "${poller.id}" created already (demo = ${poller.isDemoModeEnabled()})`); - } - poller = new IHealthPoller(id, options); - IHealthPoller._instances.push(poller); - return poller; -}; - -/** - * @param {string} id - poller's config ID - * @param {IHealthPollerOptions} [options] - options, see IHealthPoller.constructor for more details - * - * @returns {IHealthPoller} newly created iHealth Poller 'demo' instance - */ -IHealthPoller.createDemo = function create(id, options) { - options = options || {}; - options.demo = true; - return IHealthPoller.create(id, options); -}; - -/** - * Disabling and stopping iHealth Poller is tricky process and can take some time (depends on poller's state) - * to shutdown it properly. Because of it this method returns Promise resolved once poller disabled and return - * value can be used to wait until it completely died. Instance will be unregistered once disabled. - * - * @param {IHealthPoller} poller - IHealthPoller instance - * - * @returns {Promise} resolved once instance disabled (not actually stopped or died yet). - * Response contains single property 'stopPromise' than will be resolved once poller completely - * stopped (never rejects). - */ -IHealthPoller.disable = function disable(poller) { - const stopPromise = poller.stop(); - return poller.disablingPromise.then(() => { - IHealthPoller.unregister(poller); - return { - stopPromise: stopPromise - .catch((error) => { - poller.logger.exception('Unexpected exception on attempt to stop', error); - }) - .then(() => poller.cleanup()) - .catch((error) => poller.logger.exception('Unexpected exception on attempt to stop', error)) - }; - }); -}; - -/** - * @param {string} id - poller's config ID - * - * @returns {Array} iHealth Poller instances (non-demo and/or demo) - */ -IHealthPoller.get = function get(id) { - return IHealthPoller._instances.filter((p) => p.id === id); -}; - -/** - * @param {object} [options] - options - * @param {boolean} [options.demoOnly = false] - include 'demo' instances only - * @param {boolean} [options.includeDemo = false] - include 'demo' instances too - * - * @returns {Array} instances - */ -IHealthPoller.getAll = function getAll(options) { - options = util.assignDefaults(options, { - demoOnly: false, - includeDemo: false - }); - let instances = []; - if (!options.demoOnly) { - instances = instances.concat(IHealthPoller._instances.filter((p) => !p.isDemoModeEnabled())); - } - if (options.demoOnly || options.includeDemo) { - instances = instances.concat(IHealthPoller._instances.filter((p) => p.isDemoModeEnabled())); - } - return instances; -}; - -/** - * @param {IHealthPoller} poller - IHealthPoller instance or poller's config ID - * - * @returns {void} when instance unregistered - */ -IHealthPoller.unregister = function unregister(poller) { - const idx = IHealthPoller._instances.indexOf(poller); - if (idx !== -1) { - IHealthPoller._instances.splice(idx, 1); - } -}; - -/** - * PRIVATE METHODS - */ -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved with true if it is time to start next polling cycle - */ -function checkNextExecutionTime() { - const allowedToStart = shouldBeExecuted.call(this); - if (allowedToStart) { - this.data.currentCycle.startTimestamp = Date.now(); - this.logger.debug('Starting the polling cycle now'); - } else { - this.logger.debug(`Next scheduled polling cycle starts in ${timeUntilNextExecution.call(this) / 1000} s.) (${getNextExecDate.call(this).toISOString()})`); - } - return Promise.resolve(allowedToStart); -} - -/** - * @this IHealthPollerFSM - * @returns {void} once sensitive data cleaned up - */ -function cleanupSensitiveData() { - this.config = null; -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once Qkview collected - */ -function collectQkview() { - return createQkviewManager.call(this) - .then((qkviewMgr) => qkviewMgr.process()) - .then((qkviewFilePath) => { - this.data.currentCycle.qkview.qkviewFile = qkviewFilePath; - this.data.stats.qkviewsCollected += 1; - }); -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved with IHealthManager instance - */ -function createIHealthManager() { - return initConfig.call(this) - .then(decryptConfigIfNeeded.bind(this)) - .then(() => new ihealthUtil.IHealthManager(util.deepCopy(this.config.iHealth.credentials), { - logger: this.logger, - proxy: util.deepCopy(this.config.iHealth.proxy) - })); -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved with QkviewManager instance - */ -function createQkviewManager() { - return initConfig.call(this) - .then(decryptConfigIfNeeded.bind(this)) - .then(() => new ihealthUtil.QkviewManager(this.config.system.host, { - connection: util.deepCopy(this.config.system.connection), - credentials: util.deepCopy(this.config.system.credentials), - downloadFolder: this.config.iHealth.downloadFolder || constants.DEVICE_TMP_DIR, - logger: this.logger - })); -} - -/** - * @this IHealthPollerFSM - * @returns {object} base 'data' object - */ -function createStorageObject() { - const data = { - schedule: { - nextExecTime: 0 - }, - stats: { - cycles: 0, - cyclesCompleted: 0, - qkviewsCollected: 0, - qkviewCollectRetries: 0, - qkviewsUploaded: 0, - qkviewUploadRetries: 0, - reportsCollected: 0, - reportCollectRetries: 0 - }, - version: '2.0' - }; - initPollingCycleInfo.call(this, data); - return data; -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolve once configuration data decrypted - */ -function decryptConfigIfNeeded() { - if (this.config.isDecrypted) { - return Promise.resolve(); - } - return configUtil.decryptSecrets(this.config) - .then((decryptedConfig) => { - this.config = decryptedConfig; - this.config.isDecrypted = true; - }); -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved with Qkview report - */ -function fetchQkviewReport() { - let ihealthMgr; - return createIHealthManager.call(this) - .then((_ihealthMgr) => { - ihealthMgr = _ihealthMgr; - return ihealthMgr.isQkviewReportReady(this.data.currentCycle.qkview.qkviewURI); - }) - .then((isReady) => { - if (isReady) { - return ihealthMgr.fetchQkviewDiagnostics(this.data.currentCycle.qkview.qkviewURI); - } - return null; - }) - .then((reportData) => { - if (reportData) { - this.data.stats.reportsCollected += 1; - this.data.currentCycle.endTimestamp = Date.now(); - } - return { - report: reportData, - isReady: !!reportData - }; - }); -} - -/** - * Do transition to 'disabled' if needed - * - * @this IHealthPollerFSM - * @returns {boolean} true if instance was disabled - */ -function fsmDisabledIfNeeded() { - if (this.isDisabled()) { - fsmRun.call(this, () => this.transition('disabled')); - return true; - } - return false; -} - -/** - * Do following transition - current_state -> cleanup -> failed (error) - * - * @this IHealthPollerFSM - * @param {Error} error - error - */ -function fsmFatalFailure(error) { - this.transition('cleanup', 'failed', error); -} - -/** - * Do transition to the same state after delay if possible - * - * @this IHealthPollerFSM - * @param {Error} [error] - error to log - */ -function fsmRetryState(error) { - if (error) { - this.logger.debugException(`Error caught (state = ${this.state})`, error); - } - const keys = { - collectQkview: { - opt: 'QKVIEW_COLLECT', - retry: 'qkviewCollect', - stats: 'qkviewCollectRetries' - }, - collectReport: { - opt: 'QKVIEW_REPORT', - retry: 'reportCollect', - stats: 'reportCollectRetries' - }, - uploadQkview: { - opt: 'QKVIEW_UPLOAD', - retry: 'qkviewUpload', - stats: 'qkviewUploadRetries' - } - }[this.state]; - const retries = this.data.currentCycle.retries; - const stats = this.data.stats; - - if (!keys) { - fsmFatalFailure.call(this, new Error(`Unexpected state "${this.state}" in fsmRetryState()!`)); - } else if (retries[keys.retry] < constants.IHEALTH.POLLER_CONF[keys.opt].MAX_RETRIES) { - retries[keys.retry] += 1; - stats[keys.stats] += 1; - - this.logger.debug(`State "${this.state}" going to retry after sleep (#${retries[keys.retry]}, max. ${constants.IHEALTH.POLLER_CONF[keys.opt].MAX_RETRIES} retries)`); - this.transition('sleep', { - nextState: this.state, - sleepTime: constants.IHEALTH.POLLER_CONF[keys.opt].DELAY - }); - } else { - fsmFatalFailure.call(this, new Error(`Max. number of retries for state "${this.state}" reached!`)); - } -} - -/** - * Exec function with wrapped in '.catch' - * - * @this IHealthPollerFSM - * @param {Function} fn - function to run - * @param {object} [options] - options - * @param {object} [options.reject = false] - reject when caught an error - * @param {object} [options.resolve = false] - resolve when caught an error - * - * @returns {Promise} - */ -function fsmRun(fn, options) { - options = options || {}; - return Promise.resolve() - .then(fn) - .catch((error) => { - if (options.reject) { - return Promise.reject(error); - } - if (options.resolve) { - return Promise.resolve(error); - } - return fsmFatalFailure.call(this, error); - }); -} - -/** - * @this IHealthPollerFSM - * @param {Function} fn - function to run and re-run on fail - */ -function fsmRunWithRetry(fn) { - Promise.resolve() - .then(fn) - .catch(fsmRetryState.bind(this)); -} - -/** - * @this IHealthPollerFSM - * @returns {Date | null} next execution Date or null - */ -function getNextExecDate() { - const nextExecTime = this.data.schedule.nextExecTime || null; - return nextExecTime ? new Date(nextExecTime) : null; -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once System and/or iHealth config found - */ -function initConfig() { - if (!util.isObjectEmpty(this.config)) { - return Promise.resolve(); - } - return this.poller.getConfig() - .then((config) => { - this.config = config; - }); -} - -/** - * @this IHealthPollerFSM - * @param {object} data - data from PersistentStorage - * @returns {void} once info related to prev. polling cycle removed - */ -function initPollingCycleInfo(data) { - data.currentCycle = { - cycleNo: data.stats.cycles, - endTimestamp: null, - qkview: {}, - retries: { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }, - startTimestamp: null - }; -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once data from PersistentStorage received and initialized - */ -function initStorageData() { - return loadDataFromStorage.call(this.poller) - .then((data) => { - if (!util.isObjectEmpty(data) && data.version) { - this.data = data; - } - }); -} - -/** - * @this IHealthPoller - * @returns {Promise} resolved with data from PersistentStorage assigned to 'storageKey' - * or simply resolved when in 'demo' mode or disabled - */ -function loadDataFromStorage() { - if (this.isDemoModeEnabled() || this.isDisabled()) { - return Promise.resolve(); - } - return persistentStorage.get([constants.IHEALTH.STORAGE_KEY, this.storageKey]); -} - -/** - * @this IHealthPollerFSM - * @param {object} report - raw Qkview report data to process - * - * @returns {QkviewReport} processed Qkview report - */ -function processQkviewReport(report) { - // https://ihealth-api.f5.com/qkview-analyzer/api/qkviews// (last / is optional) - const currentCycle = this.data.currentCycle; - const uriParts = currentCycle.qkview.qkviewURI.split('/'); - const additionalInfo = { - ihealthLink: currentCycle.qkview.qkviewURI, - qkviewNumber: uriParts[uriParts.length - 1] || uriParts[uriParts.length - 2], - cycleStart: currentCycle.startTimestamp, - cycleEnd: currentCycle.endTimestamp - }; - return { - report, - additionalInfo - }; -} - -/** - * @this IHealthPoller - * @returns {Promise} resolved once data assigned to 'storageKey' removed - * from PersistentStorage or simply resolved when in 'demo' mode or disabled - */ -function removeDataFromStorage() { - if (this.isDemoModeEnabled()) { - return Promise.resolve(); - } - return persistentStorage.remove([constants.IHEALTH.STORAGE_KEY, this.storageKey]); -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once Qkview removed from localhost - */ -function removeQkview() { - const qkviewPath = ((this.data.currentCycle && this.data.currentCycle.qkview) || {}).qkviewFile; - if (!qkviewPath) { - return Promise.resolve(); - } - return createQkviewManager.call(this) - .then((qkviewMgr) => qkviewMgr.localDevice.removeFile(qkviewPath)); -} - -/** - * @this IHealthPoller - * @param {iHealthPollerStorage} data - data to save (JSON-serializable) - * - * @returns {Promise} resolved once data assigned and saved to PersistentStorage using 'storageKey' - * or simply resolved when in 'demo' mode or disabled - */ -function saveDataToStorage(data) { - if (this.isDemoModeEnabled() || this.isDisabled()) { - return Promise.resolve(); - } - return persistentStorage.set([constants.IHEALTH.STORAGE_KEY, this.storageKey], data); -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once next execution scheduled - */ -function scheduleNextExecution() { - return initConfig.call(this) - .then(() => { - let nextExecDate = getNextExecDate.call(this); - const fromDate = nextExecDate; - const allowNow = !nextExecDate; - nextExecDate = datetimeUtil.getNextFireDate(this.config.iHealth.interval, fromDate, allowNow); - this.data.schedule.nextExecTime = nextExecDate.getTime(); - this.logger.debug(`Next polling cycle starts on ${nextExecDate.toISOString()} (in ${timeUntilNextExecution.call(this) / 1000} s.)`); - }); -} - -/** - * @this IHealthPollerFSM - * @returns {boolean} 'true' if task should be executed (only 'true' will be returned when in 'demo' mode) - */ -function shouldBeExecuted() { - return this.isDemoModeEnabled() || timeUntilNextExecution.call(this) <= 0; -} - -/** - * @this IHealthPollerFSM - * @returns {Integer | null} number of milliseconds left till next scheduled execution, (0 when in 'demo' mode) - * or null when not available - */ -function timeUntilNextExecution() { - if (this.isDemoModeEnabled()) { - return 0; - } - const nextExecDate = getNextExecDate.call(this); - return nextExecDate ? (nextExecDate.getTime() - Date.now()) : null; -} - -/** - * @this IHealthPollerFSM - * @returns {Promise} resolved once Qkview uploaded to F5 iHealth Service - */ -function uploadQkview() { - return createIHealthManager.call(this) - .then((ihealthMgr) => ihealthMgr.uploadQkview(this.data.currentCycle.qkview.qkviewFile)) - .then((qkviewURI) => { - this.data.currentCycle.qkview.qkviewURI = qkviewURI; - this.data.stats.qkviewsUploaded += 1; - }); -} - -module.exports = IHealthPoller; - -/** - * @typedef QkviewReport - * @type {object} - * @property {object} report - Qkview report data - * @property {object} additionalInfo - additional service info - * @property {string} ihealthLink - F5 iHealth Service Qkview URI - * @property {string} qkviewNumber - Qkview ID - * @property {number} cycleStart - time when polling cycle started - * @property {number} cycleEnd - time when polling cycle ended - */ -/** - * @typedef iHealthPollerFsmPollCycle - * @type {object} - * @property {number} cycleNo - iteration number - * @property {number} endTimestamp - timestamp of when polling cycle finished - * @property {string} errorMsg - error message if poll cycle failed - * @property {object} qkview - Qkview related data - * @property {string} qkview.qkviewFile - file path to Qkview obtained from BIG-IP - * @property {string} qkview.qkviewURI - Qkview URI returned from F5 iHealth Service - * @property {boolean} qkview.reportProcessed - whether or not Qkview report was processed - * @property {object} retries - iHealth Poller retries info for current polling cycle - * @property {number} retries.qkviewCollect - number of retries made on attempt to obtain Qkview file - * @property {number} retries.qkviewUpload - number of retries made on attempt to upload Qkview file - * @property {number} retries.reportCollect - number of retries made on attempt to obtain Qkview report - * @property {number} startTimestamp - timestamp of when polling cycle started - * @property {boolean} succeed - whether or not poll cycle completed successfully - */ -/** - * @typedef iHealthPollerFsmStats - * @type {object} - * @property {number} cycles - number of polling cycles - * @property {number} cyclesCompleted - number of completed cycles - * @property {number} qkviewsCollected - number of Qkview files successfully collected - * @property {number} qkviewCollectRetries - number of retries made on attempt to obtain Qkview file - * @property {number} qkviewsUploaded - number of Qkview files successfully uploaded - * @property {number} qkviewUploadRetries - number of retries made on attempt to upload Qkview file - * @property {number} reportsCollected - number of Qkview reports successfully received - * @property {number} reportCollectRetries - number of retries made on attempt to obtain Qkview report - */ -/** - * @typedef iHealthPollerStorage - * @type {object} - * @property {iHealthPollerFsmPollCycle} currentCycle - data related to current polling cycle - * @property {string} lastKnownState - state set before data was saved - * @property {object} schedule - iHealth Poller polling schedule data - * @property {number} schedule.nextExecTime - timestamp of next scheduled execution - * @property {iHealthPollerFsmStats} stats - iHealth Poller stats - * @property {string} version - data's format version - */ -/** - * @typedef iHealthPollerFsmInfo - * @type {object} - * @property {iHealthPollerFsmPollCycle} currentCycle - current cycle info - * @property {boolean} demoMode - whether or not in 'demo' mode - * @property {boolean} disabled - whether or not instance was disabled (attempted to stop) - * @property {string} nextFireDate - next scheduled execution date - * @property {string} state - current state - * @property {iHealthPollerFsmStats} stats - iHealth Poller FSM stats - * @property {number} timeUntilNextExecution - number of ms. before next scheduled execution - */ -/** - * @typedef iHealthPollerInfo - * @type {iHealthPollerFsmInfo} - * @property {string} id - instance's config ID - * @property {string} name - instance's name - */ -/** - * @typedef IHealthPollerOptions - * @type {object} - * @property {boolean} [demo = false] - enable 'demo' mode - * @property {string} [name] - name to use (e.g. for logging) - */ diff --git a/src/lib/logger.js b/src/lib/logger.js index eb018b5c..a6b16aba 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.js @@ -198,7 +198,7 @@ class Logger { */ debugException(msg, err) { if (LOG_LEVELS.DEBUG >= CURRENT_LOG_LEVEL) { - this.logger.debug(processMessage(this.prefix, `${msg}\nTraceback:\n${(err && err.stack) || 'no traceback available'}`)); + this.logger.debug(processMessage(this.prefix, `${msg}\n${formatErr(err)}`)); } } @@ -217,7 +217,7 @@ class Logger { */ exception(msg, err) { if (LOG_LEVELS.ERROR >= CURRENT_LOG_LEVEL) { - this.logger.error(processMessage(this.prefix, `${msg}\nTraceback:\n${(err && err.stack) || 'no traceback available'}`)); + this.logger.error(processMessage(this.prefix, `${msg}\n${formatErr(err)}`)); } } @@ -245,7 +245,7 @@ class Logger { */ verboseException(msg, err) { if (LOG_LEVELS.VERBOSE >= CURRENT_LOG_LEVEL) { - this.logger.debug(processMessage(this.prefix, `${msg}\nTraceback:\n${(err && err.stack) || 'no traceback available'}`)); + this.logger.debug(processMessage(this.prefix, `${msg}\n${formatErr(err)}`)); } } @@ -259,6 +259,15 @@ class Logger { } } +/** + * @param {Error} [err] + * + * @returns {string} formatted message + */ +function formatErr(err) { + return `Message: ${(err && err.message) || err || 'no message available'}\nTraceback:\n${(err && err.stack) || 'no traceback available'}`; +} + const mainLogger = new Logger('telemetry'); /** diff --git a/src/lib/normalize.js b/src/lib/normalize.js index eecb7200..796f0076 100644 --- a/src/lib/normalize.js +++ b/src/lib/normalize.js @@ -505,51 +505,6 @@ module.exports = { return ret; }, - /** - * Normalize iHealth data - * - * @param {Object} data - data to normalize - * - * @param {Object} options - options - * @param {Object} [options.renameKeysByPattern] - contains map or array of keys to rename by pattern - * object example: { patterns: {}, options: {}} - * @param {Array} [options.filterByKeys] - array contains map of keys to filter data further - * example: { exclude: [], include: []} - * - * @returns {Object} Returns normalized event - */ - ihealth(data, options) { - options = options || {}; - - const normalized = { - system: { - hostname: data.system_information.hostname - }, - diagnostics: [] - }; - const diagnostics = ((data.diagnostics || {}).diagnostic || []); - - diagnostics.forEach((diagnostic) => { - let ret = options.filterByKeys ? normalizeUtil._filterDataByKeys(diagnostic, options.filterByKeys) - : diagnostic; - - ret = options.renameKeys - ? normalizeUtil._renameKeys(ret, options.renameKeys.patterns, options.renameKeys.options) : ret; - - const reduced = {}; - Object.assign(reduced, ret.run_data); - Object.assign(reduced, ret.results); - Object.assign(reduced, ret.fixedInVersions); - - if (reduced.version && reduced.version.length === 0) { - delete reduced.version; - } - normalized.diagnostics.push(reduced); - }); - - return normalized; - }, - /** * Normalize data - standardize and reduce complexity * diff --git a/src/lib/paths.json b/src/lib/paths.json index 2a0bd333..f79cd6cb 100644 --- a/src/lib/paths.json +++ b/src/lib/paths.json @@ -1,49 +1,49 @@ { "endpoints": [ { - "path": "/mgmt/tm/sys/global-settings" + "path": "/mgmt/tm/cm/device", + "ignoreCached": false }, { - "path": "/mgmt/tm/cm/device" + "path": "/mgmt/tm/sys/ready", + "ignoreCached": false }, { - "path": "/mgmt/tm/sys/hardware" + "path": "/mgmt/tm/cm/sync-status", + "ignoreCached": false }, { - "path": "/mgmt/tm/sys/version" + "path": "/mgmt/tm/cm/failover-status", + "ignoreCached": false }, { - "path": "/mgmt/tm/sys/ready" - }, - { - "path": "/mgmt/tm/cm/sync-status" - }, - { - "path": "/mgmt/tm/cm/failover-status" - }, - { - "path": "/mgmt/tm/sys/clock" + "path": "/mgmt/tm/sys/clock", + "ignoreCached": false }, { "path": "/mgmt/tm/sys/host-info" }, { - "path": "/mgmt/tm/sys/memory" + "path": "/mgmt/tm/sys/memory", + "ignoreCached": false }, { "path": "/mgmt/tm/sys/management-ip" }, { "name": "provisioning", - "path": "/mgmt/tm/sys/provision" + "path": "/mgmt/tm/sys/provision", + "ignoreCached": false }, { "name": "networkInterfaces", - "path": "/mgmt/tm/net/interface/stats" + "path": "/mgmt/tm/net/interface/stats", + "pagination": true }, { "name": "networkTunnels", - "path": "/mgmt/tm/net/tunnels/tunnel/stats" + "path": "/mgmt/tm/net/tunnels/tunnel/stats", + "pagination": true }, { "name": "tmmInfo", @@ -56,94 +56,108 @@ { "name": "aWideIps", "path": "/mgmt/tm/gtm/wideip/a", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "aaaaWideIps", "path": "/mgmt/tm/gtm/wideip/aaaa", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "cnameWideIps", "path": "/mgmt/tm/gtm/wideip/cname", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "mxWideIps", "path": "/mgmt/tm/gtm/wideip/mx", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "naptrWideIps", "path": "/mgmt/tm/gtm/wideip/naptr", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "srvWideIps", "path": "/mgmt/tm/gtm/wideip/srv", - "includeStats": true + "includeStats": true, + "pagination": true }, { "name": "aPools", "path": "/mgmt/tm/gtm/pool/a", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "aaaaPools", "path": "/mgmt/tm/gtm/pool/aaaa", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "cnamePools", "path": "/mgmt/tm/gtm/pool/cname", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "mxPools", "path": "/mgmt/tm/gtm/pool/mx", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "naptrPools", "path": "/mgmt/tm/gtm/pool/naptr", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "srvPools", "path": "/mgmt/tm/gtm/pool/srv", "includeStats": true, + "pagination": true, "expandReferences": { "membersReference": { "includeStats": true } } }, { "name": "virtualServers", "path": "/mgmt/tm/ltm/virtual", "includeStats": true, - "endpointFields": [ "name", "fullPath", "selfLink", "appService", "ipProtocol", "mask", "pool", "profilesReference" ], + "pagination": true, "expandReferences": { "profilesReference": { "endpointSuffix": "?$select=name,fullPath" } } }, { "name": "pools", "path": "/mgmt/tm/ltm/pool", + "pagination": true, "includeStats": true, "expandReferences": { "membersReference": { "includeStats": true, "endpointSuffix": "?$select=fqdn,selfLink" } } }, { "name": "ltmPolicies", - "path": "/mgmt/tm/ltm/policy/stats" + "path": "/mgmt/tm/ltm/policy/stats", + "pagination": true }, { "name": "sslCerts", - "path": "/mgmt/tm/sys/file/ssl-cert" + "path": "/mgmt/tm/sys/file/ssl-cert", + "pagination": true }, { "name": "diskStorage", "path": "/mgmt/tm/util/bash", - "ignoreCached": true, "body": { "command": "run", "utilCmdArgs": "-c \"/bin/df -P | /usr/bin/tr -s ' ' ','\"" @@ -152,7 +166,6 @@ { "name": "diskLatency", "path": "/mgmt/tm/util/bash", - "ignoreCached": true, "body": { "command": "run", "utilCmdArgs": "-c \"/usr/bin/iostat -x -d | /usr/bin/tail -n +3 | /usr/bin/tr -s ' ' ','\"" @@ -160,15 +173,18 @@ }, { "name": "httpProfiles", - "path": "/mgmt/tm/ltm/profile/http/stats" + "path": "/mgmt/tm/ltm/profile/http/stats", + "pagination": true }, { "name": "clientSslProfiles", - "path": "/mgmt/tm/ltm/profile/client-ssl/stats" + "path": "/mgmt/tm/ltm/profile/client-ssl/stats", + "pagination": true }, { "name": "serverSslProfiles", - "path": "/mgmt/tm/ltm/profile/server-ssl/stats" + "path": "/mgmt/tm/ltm/profile/server-ssl/stats", + "pagination": true }, { "name": "deviceGroups", @@ -177,7 +193,8 @@ }, { "name": "asmQuery", - "path": "/mgmt/tm/asm/policies" + "path": "/mgmt/tm/asm/policies", + "pagination": true }, { "name": "asmAttackSignaturesInstallations", @@ -187,7 +204,6 @@ { "name": "apmState", "path": "/mgmt/tm/util/bash", - "ignoreCached": true, "body": { "command": "run", "utilCmdArgs": "-c \"/bin/unbuffer /usr/bin/guishell -c \\\"select max(config_sync_state) from profile_access_misc_stat;\\\" | tr '\\n' ' ' | sed -r 's/.*\\\\|\\\\s*\\\\|.*\\\\|\\\\s*([^|]*)\\\\s*\\\\|.*/apm_state\\n\\\\1/'\"" @@ -195,7 +211,8 @@ }, { "name": "firewallCurrentState", - "path": "/mgmt/tm/security/firewall/current-state/stats" + "path": "/mgmt/tm/security/firewall/current-state/stats", + "ignoreCached": false }, { "name": "ltmConfigTime", @@ -207,12 +224,12 @@ }, { "name": "iRules", - "path": "/mgmt/tm/ltm/rule/stats" + "path": "/mgmt/tm/ltm/rule/stats", + "pagination": true }, { "name": "tmctl", "path": "/mgmt/tm/util/bash", - "ignoreCached": true, "body": { "command": "run", "utilCmdArgs": "-c 'tmctl $tmctlArgs'" @@ -220,11 +237,15 @@ }, { "name": "deviceInfo", - "path": "/mgmt/shared/identified-devices/config/device-info" + "path": "/mgmt/shared/identified-devices/config/device-info", + "ignoreCached": false }, { "name": "throughputPerformance", - "path": "/mgmt/tm/sys/performance/throughput/stats?options=detail", + "path": "/mgmt/tm/sys/performance/throughput/stats", + "query": { + "options": "detail" + }, "parseDuplicateKeys": true }, { @@ -233,7 +254,10 @@ }, { "name": "connectionsPerformance", - "path": "/mgmt/tm/sys/performance/connections/stats?options=detail", + "path": "/mgmt/tm/sys/performance/connections/stats", + "query": { + "options": "detail" + }, "parseDuplicateKeys": true } ] diff --git a/src/lib/persistentStorage.js b/src/lib/persistentStorage.js deleted file mode 100644 index c6dc73aa..00000000 --- a/src/lib/persistentStorage.js +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const getData = require('lodash/get'); -const setData = require('lodash/set'); -const unsetData = require('lodash/unset'); - -const logger = require('./logger'); -const util = require('./utils/misc'); - -/** @module persistentStorage */ - -/** - * Interface for classes that represents a Storage for data - * - * @class - * - * Note: when 'key' arg is Array then it should be treated as path - ['a', 'b', 'c'] is eq to 'a.b.c' - */ -class StorageInterface { - /** - * Get data by the specified key - * - * @param {string|Array} key - key to be searched in the storage - * - * @returns {Promise} resolved with copy of a data - */ - get() { - throw new Error('Not implemented'); - } - - /** - * Set data to the specified key - * - * @param {string|Array} resolved with data loaded from the storage - */ - load() { - throw new Error('Not implemented'); - } - - /** - * Save storage data - * - * @returns {Promise} resolved when data saved to the storage - */ - save() { - throw new Error('Not implemented'); - } -} - -/** - * Persistent Storage Proxy - * - * @class - * - * @property {Storage} storage - storage instance - */ -class PersistentStorageProxy extends StorageInterface { - /** - * Constructor - * - * @param {Storage} storage - instance of persistent storage - */ - constructor(storage) { - super(); - this.storage = storage; - } - - /** @inheritdoc */ - get(key) { - return this.storage.get(key) - .then((value) => { - if (typeof value === 'object') { - value = util.deepCopy(value); - } - return Promise.resolve(value); - }); - } - - /** @inheritdoc */ - set(key, data) { - if (typeof data === 'object') { - data = util.deepCopy(data); - } - return this.storage.set(key, data); - } - - /** @inheritdoc */ - remove(key) { - return this.storage.remove(key); - } - - /** @inheritdoc */ - load() { - return this.storage.load(); - } - - /** @inheritdoc */ - save() { - return this.storage.save(); - } -} - -/** - * Rest Storage Interface. - * 'get' - not async, reading all data from cache and calls callback immediately. - * 'set' - semi-async, saves data to cache immediately, but callback will be called - * once data is really saved to Rest Storage. Cache can be not in-sync - * with the data in Rest Storage. - * 'remove' - semi-async, removes data from cache immediately, but callback will - * be called once updated data is really saved to Rest Storage. Cache can be - * not in-sync with the actual data in Rest Storage. - * - * @class - * - * @property {module:restWorkers~RestWorker} restWorker - RestWorker instance - */ -class RestStorage extends StorageInterface { - /** - * Constructor - * - * @param {RestWorker} restWorker - RestWorker instance - */ - constructor(restWorker) { - super(); - this.restWorker = restWorker; - this._cache = this._getBaseState(); - - this._savePromise = null; - this._inSaveProcess = false; - this._loadPromise = null; - } - - /** @inheritdoc */ - get(key) { - return Promise.resolve(getData(this._cache._data_, key)); - } - - /** @inheritdoc */ - set(key, data) { - setData(this._cache._data_, key, data); - return this._save(); - } - - /** @inheritdoc */ - remove(key) { - unsetData(this._cache._data_, key); - return this._save(); - } - - /** @inheritdoc */ - save() { - return this._save(); - } - - /** @inheritdoc */ - load() { - return this._load(); - } - - /** - * Load all data from Rest Storage. - * Can be blocked for a while by save operation. - * - * @async - * @private - * - * @returns {Promise} resolved when data loaded from Rest Storage - */ - _load() { - if (!this.restWorker) { - // fatal error - throw new Error('RestStorage.load: restWorker is not specified'); - } - - let loadPromise = this._loadPromise; - - if (!loadPromise) { - loadPromise = this._savePromise ? this._savePromise : Promise.resolve(); - loadPromise = loadPromise.then(() => this._unsafeLoad()) - .then((state) => { - this._loadPromise = null; - this._cache = this._validateLoadedState(state || this._getBaseState()); - loadPromise.loadResults = this._cache._data_; - logger.debug('RestStorage.load: application state loaded'); - }) - .catch((err) => { - this._loadPromise = null; - loadPromise.loadError = err; - logger.exception('RestStorage.load error', err); - }); - - this._loadPromise = loadPromise; - } - - return new Promise((resolve, reject) => { - loadPromise.then(() => { - if (loadPromise.loadError) { - reject(loadPromise.loadError); - } else { - resolve(loadPromise.loadResults); - } - }); - }); - } - - /** - * Save current data to Rest Storage. - * Can be blocked for a while by load operation. - * - * @returns {Promise} resolved when data saved to Rest Storage - */ - _save() { - if (!this.restWorker) { - // fatal error - throw new Error('RestStorage.save: restWorker is not specified'); - } - if (!this._cache) { - // fatal error - throw new Error('RestStorage.save: no loaded state'); - } - - let savePromise = this._savePromise; - if (!savePromise) { - let copiedData; - - savePromise = this._loadPromise ? this._loadPromise : Promise.resolve(); - savePromise = savePromise.then(() => { - this._inSaveProcess = true; - try { - copiedData = this._prepareToSave(this._cache); - } catch (err) { - return Promise.reject(err); - } - return this._unsafeSave(copiedData); - }) - .then(() => { - /** - * Once data passed to restWorker.saveState it might be modified by restWorker. - * We need to copy those 'service' properties back to '_cache' to be able to - * save data again later. We can't assign data directly like 'this._cache = copiedData' - * because '_cache' might be updated by the user already - e.g. new data set. - */ - Object.keys(copiedData).forEach((key) => { - if (key !== '_data_') { - this._cache[key] = copiedData[key]; - } - }); - logger.debug('RestStorage.save: application state saved'); - }) - .catch((err) => { - savePromise.saveError = err; - logger.exception('RestStorage.save error', err); - }) - .then(() => { - this._savePromise = null; - this._inSaveProcess = false; - }); - - this._savePromise = savePromise; - } else if (this._inSaveProcess) { - // try again to save later, because we are too late at that time - savePromise = savePromise.then(() => this._save()); - } - - return new Promise((resolve, reject) => { - savePromise.then(() => { - if (savePromise.saveError) { - reject(savePromise.saveError); - } else { - resolve(); - } - }); - }); - } - - /** - * Save data (override existing one) to Rest Storage without any checks - * - * @param {object} data - data to save - * - * @returns {Promise} resolved when data saved to Rest Storage - */ - _unsafeSave(data) { - const self = this; - return new Promise((resolve, reject) => { - self.restWorker.saveState(null, data, (err) => { - if (err) { - logger.error(`RestStorage._unsafeSave: unable to save state: ${err}`); - reject(err); - } else { - resolve(); - } - }); - }); - } - - /** - * Load data from Rest Storage without any checks - * - * @returns {Promise} resolved with data loaded from Rest Storage - */ - _unsafeLoad() { - const self = this; - return new Promise((resolve, reject) => { - self.restWorker.loadState(null, (err, state) => { - if (err) { - logger.error(`RestStorage._unsafeLoad: unable to load state: ${err}`); - reject(err); - } else { - resolve(state); - } - }); - }); - } - - /** - * Base state - * - * @private - * - * @returns {object} base state object - */ - _getBaseState() { - return { - _data_: {} - }; - } - - /** - * Validate loaded data and convert it to appropriate format - * - * @private - * - * @param {object} state - object to verify - * - * @returns {object} verified object - */ - _validateLoadedState(state) { - // looks like it is old versions - if (typeof state._data_ !== 'undefined') { - if (typeof state._data_ === 'string') { - state._data_ = JSON.parse(state._data_); - } - if (state._data_ === null) { - // otherwise lodash.set will ignore all operations - state._data_ = {}; - } - } else { - state._data_ = {}; - if (typeof state.config !== 'undefined') { - state._data_.config = state.config; - delete state.config; - } - } - return state; - } - - /** - * Prepare data to be saved - * - * @private - * - * @param {object} state - current state - * - * @returns {object} object ready to be saved - */ - _prepareToSave(state) { - state = state || {}; - state._data_ = state._data_ || {}; - - const newState = Object.assign({}, state); - newState._data_ = JSON.stringify(state._data_); - - return newState; - } -} - -module.exports = { - /** - * Creating singleton instance that will be configured later in restWorker.js and shared across modules - * - * Usage: - * require('./persistentStorage').persistentStorage.get(key) - */ - persistentStorage: new PersistentStorageProxy(), - PersistentStorageProxy, - RestStorage, - StorageInterface -}; diff --git a/src/lib/properties.json b/src/lib/properties.json index 26015562..60c9ef92 100644 --- a/src/lib/properties.json +++ b/src/lib/properties.json @@ -1,12 +1,8 @@ { "stats": { - "system": { - "structure": { "folder": true } - }, "hostname": { "key": "deviceInfo::hostname", "structure": { "parentKey": "system" }, - "normalize": null, "normalization": [ { "convertArrayToMap": null @@ -102,15 +98,7 @@ }, "configReady": { "structure": { "parentKey": "system" }, - "if": { - "deviceVersionGreaterOrEqual": "13.1" - }, - "then": { - "key": "/mgmt/tm/sys/ready::sys/ready/0::configReady" - }, - "else": { - "disabled": true - } + "key": "/mgmt/tm/sys/ready::sys/ready/0::configReady" }, "configSyncSucceeded": { "structure": { "parentKey": "system" }, @@ -123,27 +111,11 @@ }, "licenseReady": { "structure": { "parentKey": "system" }, - "if": { - "deviceVersionGreaterOrEqual": "13.1" - }, - "then": { - "key": "/mgmt/tm/sys/ready::sys/ready/0::licenseReady" - }, - "else": { - "disabled": true - } + "key": "/mgmt/tm/sys/ready::sys/ready/0::licenseReady" }, "provisionReady": { "structure": { "parentKey": "system" }, - "if": { - "deviceVersionGreaterOrEqual": "13.1" - }, - "then": { - "key": "/mgmt/tm/sys/ready::sys/ready/0::provisionReady" - }, - "else": { - "disabled": true - } + "key": "/mgmt/tm/sys/ready::sys/ready/0::provisionReady" }, "syncMode": { "structure": { "parentKey": "system" }, @@ -1003,9 +975,6 @@ } ] }, - "tmstats": { - "structure": { "folder": true } - }, "asmCpuUtilStats": { "structure": { "parentKey": "tmstats" }, "if": { @@ -1969,22 +1938,6 @@ }, "addTimestampForCategories": [ "APM" ] }, - "ihealth": { - "filterKeys": { "exclude": [ "name", "output", "match" ] }, - "renameKeys": { - "patterns": { - "h_importance": { "constant": "importance" }, - "h_action": { "constant": "action" }, - "h_name": { "constant": "name" }, - "h_cve_ids": { "constant": "cveIds" }, - "h_header": { "constant": "header" }, - "h_summary": { "constant": "summary" } - }, - "options": { - "exactMatch": true - } - } - }, "context": { "HOSTNAME": { "key": "deviceInfo::hostname" diff --git a/src/lib/pullConsumers.js b/src/lib/pullConsumers.js deleted file mode 100644 index 87dbd59e..00000000 --- a/src/lib/pullConsumers.js +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const path = require('path'); - -const configWorker = require('./config'); -const constants = require('./constants'); -const util = require('./utils/misc'); -const systemPoller = require('./systemPoller'); -const errors = require('./errors'); -const logger = require('./logger'); -const configUtil = require('./utils/config'); -const tracerMgr = require('./tracerManager'); -const promiseUtil = require('./utils/promise'); -const moduleLoader = require('./utils/moduleLoader').ModuleLoader; - -const PULL_CONSUMERS_DIR = '../pullConsumers'; -let PULL_CONSUMERS = []; - -class ModuleNotLoadedError extends errors.ConfigLookupError {} -class DisabledError extends errors.ConfigLookupError {} - -/** - * Get data for Pull Consumer - * - * @param {String} consumerName - consumer name - * @param {String} namespace - optional namespace - */ -function getData(consumerName, namespace) { - let config; // to pass to systemPoller - let consumerConfig; - namespace = namespace || constants.DEFAULT_UNNAMED_NAMESPACE; - return Promise.resolve() - .then(() => { - // config was copied by getConfig already - config = configWorker.currentConfig; - - consumerConfig = getConsumerConfig(config, consumerName, namespace); - // Don't bother collecting stats if requested Consumer Type is not loaded - if (!PULL_CONSUMERS.find((pc) => pc.config.type === consumerConfig.type)) { - throw new ModuleNotLoadedError(`Pull Consumer of type '${consumerConfig.type}' is not loaded`); - } - - const pollerGroup = configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - config, - consumerConfig - ); - const pollerConfigs = configUtil.getTelemetrySystemPollersForGroup(config, pollerGroup) - .filter((sp) => sp.enable); - - return systemPoller.fetchPollersData(util.deepCopy(pollerConfigs), true); - }) - .then((pollerData) => invokeConsumer(consumerConfig, pollerData)); -} - -/** - * Invoke the Consumer's processing. - * Sends the 'raw' data collected from System Poller(s) to the configured Pull Consumer. - * Gets data from the Pull Consumer, after Pull Consumer's formatting logic has been applied. - * - * @param {Object} consumerConfig - Configuration of Pull Consumer - * @param {Array} dataCtxs - Complete array of dataCtx objects from associated System Poller(s) - * - * @returns {Promise} Promise which is resolved with data formatted by the requested Pull Consumer - */ -function invokeConsumer(consumerConfig, dataCtxs) { - const consumer = PULL_CONSUMERS.find((pc) => pc.id === consumerConfig.id); - - const context = { - config: consumerConfig, - event: dataCtxs, - logger: consumer.logger, - tracer: consumer.tracer - }; - return consumer.consumer(context); -} - -function getConsumerConfig(config, consumerName, namespace) { - const consumers = configUtil.getTelemetryPullConsumers(config, namespace); - const namespaceInfo = namespace ? ` (namespace: ${namespace})` : ''; - if (consumers.length > 0) { - const consumer = consumers.find((c) => c.name === consumerName); - if (util.isObjectEmpty(consumer)) { - throw new errors.ObjectNotFoundInConfigError(`Pull Consumer with name '${consumerName}' doesn't exist${namespaceInfo}`); - } - if (consumer.enable === false) { - throw new DisabledError(`Pull Consumer with name '${consumerName}' is disabled${namespaceInfo}`); - } - return consumer; - } - - throw new errors.ObjectNotFoundInConfigError(`No configured Pull Consumers found${namespaceInfo}`); -} - -/** -* Load plugins for requested pull consumers -* -* @param {Object} config - config object -* @param {Array} config.consumers - array of pull consumers to load -* @param {string} config.consumers[].consumer - pull consumer name/type -* -* @returns {Object} Promise object with resolves with array of - loaded plugins. Looks like following: - [ - { - consumer: function(context), - config: [object] - }, - ... - ] -*/ -function loadConsumers(config) { - if (config.length === 0) { - logger.info('No pull consumer(s) to load, define in configuration first'); - return Promise.resolve([]); - } - const enabledConsumers = config.filter((c) => c.enable); - if (enabledConsumers.length === 0) { - logger.debug('No enabled pull consumer(s) to load'); - return Promise.resolve([]); - } - - logger.debug(`Loading pull consumer specific plug-ins from ${PULL_CONSUMERS_DIR}`); - - const loadPromises = enabledConsumers.map((consumerConfig) => new Promise((resolve) => { - const existingConsumer = PULL_CONSUMERS.find((c) => c.id === consumerConfig.id); - if (consumerConfig.skipUpdate && existingConsumer) { - resolve(existingConsumer); - } else { - const consumerType = consumerConfig.type; - const consumerDir = path.join(PULL_CONSUMERS_DIR, consumerType); - - logger.debug(`Loading pull consumer ${consumerType} plug-in from ${consumerDir}`); - const consumerModule = moduleLoader.load(consumerDir); - if (consumerModule === null) { - resolve(undefined); - } else { - const consumer = { - name: consumerConfig.name, - id: consumerConfig.id, - config: util.deepCopy(consumerConfig), - consumer: consumerModule, - logger: logger.getChild(`${consumerType}.${consumerConfig.traceName}`), - tracer: tracerMgr.fromConfig(consumerConfig.trace) - }; - // copy consumer's data - resolve(consumer); - } - } - })); - - return promiseUtil.allSettled(loadPromises) - .then((results) => promiseUtil.getValues(results).filter((c) => c !== undefined)); -} - -/** - * Get set of loaded Consumers' types - * - * @returns {Set} set with loaded Consumers' types - */ -function getLoadedConsumerTypes() { - if (PULL_CONSUMERS.length > 0) { - return new Set(PULL_CONSUMERS.map((consumer) => consumer.config.type)); - } - return new Set(); -} - -/** - * Unload unused modules from cache - * - * @param {Set} before - set of Consumers' types before - */ -function unloadUnusedModules(before) { - if (!before.size) { - return; - } - - const loadedTypes = getLoadedConsumerTypes(); - before.forEach((consumerType) => { - if (!loadedTypes.has(consumerType)) { - logger.debug(`Unloading Pull Consumer module '${consumerType}'`); - const consumerDir = path.join(PULL_CONSUMERS_DIR, consumerType); - - moduleLoader.unload(consumerDir); - } - }); -} - -// config worker change event -configWorker.on('change', (config) => Promise.resolve() - .then(() => { - logger.debug('configWorker change event in Pull Consumers'); - - const consumersToLoad = configUtil.getTelemetryPullConsumers(config); - const typesBefore = getLoadedConsumerTypes(); - - return loadConsumers(consumersToLoad) - .then((consumers) => { - PULL_CONSUMERS = consumers; - logger.info(`${PULL_CONSUMERS.length} pull consumer plug-in(s) loaded`); - }) - .catch((err) => { - logger.exception('Unhandled exception when loading consumers', err); - }) - .then(() => unloadUnusedModules(typesBefore)); - })); - -module.exports = { - ModuleNotLoadedError, - DisabledError, - getData, - getConsumers: () => PULL_CONSUMERS // expose for testing -}; diff --git a/src/lib/pullConsumers/default/index.js b/src/lib/pullConsumers/default/index.js deleted file mode 100644 index 8fe9c62b..00000000 --- a/src/lib/pullConsumers/default/index.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * Apply consumer-specific formatting, and return the reformatted data - * - * @param {Object} context - complete context object - * @param {Object} context.config - consumer's config from declaration - * @param {Object} context.logger - logger instance - * @param {function(string):void} context.logger.info - log info message - * @param {function(string):void} context.logger.error - log error message - * @param {function(string):void} context.logger.debug - log debug message - * @param {function(string, err):void} context.logger.exception - log error message with error's traceback - * @param {Array} context.event - array of events to process - * @param {Object} context.event[].data - actual data object to process - * @param {Object|undefined} context.tracer - tracer object - * @param {function(string):void} context.tracer.write - write data to tracer - * - * @returns {Promise} - Promise resolved with the consumer-specific formatting applied, - * or rejected if no events are provided - */ -module.exports = function (context) { - const logger = context.logger; - const event = context.event; - const config = context.config; // eslint-disable-line no-unused-vars - const tracer = context.tracer; - - if (!Array.isArray(event)) { - const msg = 'No event data to process'; - logger.error(msg); - return Promise.reject(new Error(msg)); - } - - // Re-format event data into array of objects - const formattedData = event - .filter((d) => (typeof d !== 'undefined' && Object.keys(d).indexOf('data') !== -1)) - .map((d) => d.data); - - logger.verbose('success'); - if (tracer) { - // pretty JSON dump - tracer.write(formattedData); - } - return Promise.resolve({ data: formattedData }); -}; diff --git a/src/lib/pullConsumers/index.js b/src/lib/pullConsumers/index.js new file mode 100644 index 00000000..e2a21b5d --- /dev/null +++ b/src/lib/pullConsumers/index.js @@ -0,0 +1,275 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const { CONFIG_CLASSES, DATA_PIPELINE, DEFAULT_UNNAMED_NAMESPACE } = require('../constants'); +const errors = require('../errors'); +const promiseUtil = require('../utils/promise'); +const Service = require('../utils/service'); + +/** + * @module pullConsumers + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('../logger').Logger} Logger + * @typedef {import('../restAPI').Register} RegisterRestApiHandler + * @typedef {import('../restAPI').RequestHandler} RestApiHandler + */ + +const EE_NAMESPACE = 'pullconsumers'; + +class PullConsumerService extends Service { + /** @inheritdoc */ + async _onStart() { + this._consumers = {}; + this._pollers = {}; + + // start listening for events + this._registerEvents(); + } + + /** @inheritdoc */ + async _onStop() { + // stop receiving config updates + this._configListener.off(); + this._configListener = null; + + // stop receiving consumers updates + this._consumersListener.off(); + this._consumersListener = null; + + // stop receiving REST API updates + this._restApiListener.off(); + this._restApiListener = null; + + if (this._offRestApiHandlers) { + await this._offRestApiHandlers(); + } + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + + this._consumers = null; + this._pollers = null; + } + + /** @returns {number} number of active PULL consumers */ + get numberOfConsumers() { + return Object.keys(this._consumers).length; + } + + /** @returns {number} number of active PASSIVE pollers */ + get numberOfPollers() { + return Object.keys(this._pollers).length; + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._configListener = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._consumersListener = appEvents.on('consumers.change', onConsumersChange.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Consumers updates.'); + + this._restApiListener = appEvents.on('restapi.register', onRestApi.bind(this), { objectify: true }); + this.logger.debug('Subscribed to REST API updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + 'config.applied', + 'systemPoller.collect' + ]); + }; + } +} + +/** + * @this PullConsumerService + * + * @param {Configuration} config + */ +function onConfigEvent(config) { + Promise.resolve() + .then(() => { + this.logger.debug('Config "change" event'); + + this._pollers = {}; + + config.components + .filter((comp) => comp.enable + && comp.class === CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME) + .forEach((comp) => { + this._pollers[comp.id] = { + consumer: comp, + id: comp.id + }; + }); + + config.components + .filter((comp) => comp.enable + && comp.class === CONFIG_CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME) + .forEach((comp) => { + this._pollers[comp.pullConsumer].poller = comp; + }); + }) + .catch((err) => { + this._pollers = {}; + this.logger.exception('Error caught on attempt to apply configuration to Pull Consumer Service:', err); + }) + // emit in any case to show we are done with config processing + .then(() => { + this.logger.info(`${this.numberOfPollers} system pollers registered`); + this.ee.safeEmitAsync('config.applied', { + numberOfPollers: this.numberOfPollers + }); + }); +} + +/** + * @this PullConsumerService + * + * @param {GetConsumers} getConsumers + */ +function onConsumersChange(getConsumers) { + Promise.resolve() + .then(() => { + this.logger.debug('Consumers "change" event'); + + this._consumers = {}; + getConsumers() + .filter((consumerCtx) => consumerCtx.allowsPull) + .forEach((consumerCtx) => { + this._consumers[consumerCtx.id] = consumerCtx.consumer; + }); + }) + .catch((err) => { + this._consumers = {}; + this.logger.exception('Error caught on attempt to apply consumers configuration to Pull Consumer Service:', err); + }) + // emit in any case to show we are done with config processing + .then(() => { + this.logger.info(`${this.numberOfConsumers} pull consumers registered`); + this.ee.safeEmitAsync('consumer.applied', { + numberOfConsumers: this.numberOfConsumers + }); + }); +} + +/** + * Apply REST API configuration + * + * @this PullConsumerService + * + * @param {RegisterRestApiHandler} register - register handler + */ +async function onRestApi(register) { + // previous handler (if registered) destroyed already + const requestHandler = makeRequestHandler.call(this); + + const offs = [ + register('GET', '/pullconsumer/:consumer', requestHandler), + register('GET', '/namespace/:namespace/pullconsumer/:consumer', requestHandler) + ]; + this._offRestApiHandlers = () => promiseUtil.allSettled(offs.map((off) => off())); +} + +/** + * @this PullConsumerService + * + * @returns {RestApiHandler} + */ +function makeRequestHandler() { + const service = this; + /** + * @implements {RestApiHandler} + */ + return Object.freeze({ + async collectStats(config) { + if (config.poller.systemPollers.length === 0) { + return []; + } + + const promises = config.poller.systemPollers.map((pollerID) => { + const resolvers = promiseUtil.withResolvers(); + const emitErr = service.ee.safeEmit('systemPoller.collect', pollerID, (error, dataCtx) => { + if (error) { + resolvers.reject(error); + } else { + resolvers.resolve(dataCtx); + } + }); + if (typeof emitErr !== 'boolean') { + resolvers.reject(emitErr); + } + return resolvers.promise; + }); + + return promiseUtil.getValues(await promiseUtil.allSettled(promises)); + }, + + getConfig(consumerName, namespace = DEFAULT_UNNAMED_NAMESPACE) { + const poller = Object.values(service._pollers) + .find((rec) => rec.consumer.name === consumerName && rec.consumer.namespace === namespace); + + let consumer; + if (typeof poller !== 'undefined') { + consumer = service._consumers[poller.id]; + } + if (typeof consumer === 'undefined') { + throw new errors.ObjectNotFoundInConfigError(`No active confugration found for Pull Consumer "${consumerName}" (${namespace})`); + } + return { + consumer, + id: poller.id, + poller: poller.poller + }; + }, + + processStats(config, rawStats) { + // TODO: update to send data via dataPipeline + const resolvers = promiseUtil.withResolvers(); + config.consumer(rawStats, DATA_PIPELINE.PUSH_PULL_EVENT, (error, response) => { + if (error) { + resolvers.reject(error); + } else { + resolvers.resolve(response); + } + }); + return resolvers.promise; + }, + + /** @inheritdoc */ + async handle(req, res) { + const uriParams = req.getUriParams(); + const config = this.getConfig(uriParams.consumer, uriParams.namespace); + const rawStats = await this.collectStats(config); + const response = await this.processStats(config, rawStats); + + res.body = response.data; + res.code = 200; + res.contentType = response.contentType || undefined; + }, + name: 'System Poller Service' + }); +} + +module.exports = PullConsumerService; diff --git a/src/lib/requestHandlers/baseHandler.js b/src/lib/requestHandlers/baseHandler.js deleted file mode 100644 index 7d82db0f..00000000 --- a/src/lib/requestHandlers/baseHandler.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * Base class for Request Handlers - */ -class BaseRequestHandler { - /** - * Constructor - * - * @param {Object} restOperation - * @param {Object} params - */ - constructor(restOperation, params) { - this.restOperation = restOperation; - this.params = params; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - throw new Error('Method "getBody" not implemented'); - } - - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - throw new Error('Method "getCode" not implemented'); - } - - /** - * Get HTTP headers - * - * @returns {Object} HTTP headers - */ - getHeaders() { - return this.restOperation.getHeaders() || {}; - } - - /** - * Get HTTP method name - * - * @returns {String} HTTP method name converted to upper case - */ - getMethod() { - return this.restOperation.getMethod().toUpperCase(); - } - - /** - * Get HTTP Content-Type - * Base Handler returns undefined, allowing iControlRest to set Content-Type - * - * @returns {undefined} HTTP Content-Type header value - */ - getContentType() { - return undefined; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of BaseRequestHandler - * once request processed - */ - process() { - return Promise.resolve(this); - } -} - -module.exports = BaseRequestHandler; diff --git a/src/lib/requestHandlers/declareHandler.js b/src/lib/requestHandlers/declareHandler.js deleted file mode 100644 index 3e3cd4ad..00000000 --- a/src/lib/requestHandlers/declareHandler.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const deepCopy = require('../utils/misc').deepCopy; -const ErrorHandler = require('./errorHandler'); -const httpErrors = require('./httpErrors'); -const configWorker = require('../config'); -const logger = require('../logger'); -const router = require('./router'); - -/** - * Request handler for /declare endpoint - */ -class DeclareEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of DeclareEndpointHandler - * once request processed - */ - process() { - let promise; - const namespace = this.params && this.params.namespace ? this.params.namespace : undefined; - - if (this.getMethod() === 'POST') { - if (DeclareEndpointHandler.PROCESSING_DECLARATION_FLAG) { - logger.debug('Can\'t process new declaration while previous one is still in progress'); - return Promise.resolve(new ErrorHandler(new httpErrors.ServiceUnavailableError())); - } - - const declaration = this.restOperation.getBody(); - // compute request metadata - const metadata = { - message: 'Incoming declaration via REST API', - originDeclaration: deepCopy(declaration) - }; - if (namespace) { - metadata.namespace = namespace; - } - metadata.sourceIP = this.getHeaders()['X-Forwarded-For']; - - DeclareEndpointHandler.PROCESSING_DECLARATION_FLAG = true; - promise = namespace - ? configWorker.processNamespaceDeclaration(declaration, this.params.namespace, { metadata }) - : configWorker.processDeclaration(declaration, { metadata }); - - promise = promise - .then((config) => { - DeclareEndpointHandler.PROCESSING_DECLARATION_FLAG = false; - return config; - }) - .catch((err) => { - DeclareEndpointHandler.PROCESSING_DECLARATION_FLAG = false; - return Promise.reject(err); - }); - } else { - promise = configWorker.getDeclaration(namespace); - } - - return promise.then((config) => { - this.code = 200; - this.body = { - message: 'success', - declaration: config - }; - return this; - }) - .catch((error) => new ErrorHandler(error).process()); - } -} - -DeclareEndpointHandler.PROCESSING_DECLARATION_FLAG = false; - -router.on('register', (routerInst) => { - routerInst.register('GET', '/declare', DeclareEndpointHandler); - routerInst.register('POST', '/declare', DeclareEndpointHandler); - routerInst.register('GET', '/namespace/:namespace/declare', DeclareEndpointHandler); - routerInst.register('POST', '/namespace/:namespace/declare', DeclareEndpointHandler); -}); - -module.exports = DeclareEndpointHandler; diff --git a/src/lib/requestHandlers/errorHandler.js b/src/lib/requestHandlers/errorHandler.js deleted file mode 100644 index a0140512..00000000 --- a/src/lib/requestHandlers/errorHandler.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const errors = require('../errors'); -const HttpError = require('./httpErrors').HttpError; - -/** - * Handler for errors encountered during requests - */ -class ErrorHandler extends BaseRequestHandler { - /** - * Constructor - * - * @param {Object} error - Error object - */ - constructor(error) { - super(); - this.error = error; - } - - getCode() { - if (this.error instanceof HttpError) { - return this.error.getCode(); - } - if (this.error instanceof errors.BaseError) { - const httpError = this.getHttpEquivalent(this.error); - return httpError.code; - } - return undefined; - } - - getBody() { - if (this.error instanceof HttpError) { - return this.error.getBody(); - } - if (this.error instanceof errors.BaseError) { - const httpError = this.getHttpEquivalent(this.error); - return httpError.body; - } - return undefined; - } - - process() { - if (this.error instanceof HttpError) { - this.code = this.getCode(); - this.body = this.getBody(); - return Promise.resolve(this); - } - - if (this.error instanceof errors.BaseError) { - const httpError = this.getHttpEquivalent(this.error); - this.code = httpError.code; - this.body = httpError.body; - return Promise.resolve(this); - } - - return Promise.reject(this.error); - } - - getHttpEquivalent(error) { - const httpError = {}; - if (error instanceof errors.ConfigLookupError) { - httpError.code = 404; - httpError.body = { - code: 404, - message: error.message - }; - } else if (error instanceof errors.ValidationError) { - httpError.code = 422; - httpError.body = { - code: 422, - message: 'Unprocessable entity', - error: error.message - }; - } - return httpError; - } -} - -module.exports = ErrorHandler; diff --git a/src/lib/requestHandlers/eventListenerHandler.js b/src/lib/requestHandlers/eventListenerHandler.js deleted file mode 100644 index cc1ab798..00000000 --- a/src/lib/requestHandlers/eventListenerHandler.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const ErrorHandler = require('./errorHandler'); -const router = require('./router'); -const dataPublisher = require('../eventListener/dataPublisher'); - -/** - * Request handler for /eventListener endpoint - */ -class EventListenerEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of EventListenerEndpointHandler - * once request processed - */ - process() { - const dataToSend = this.restOperation.getBody(); - return dataPublisher.sendDataToListener( - dataToSend, - this.params.eventListener, - { namespace: this.params.namespace } - ) - .then(() => { - this.body = { - message: 'success', - data: dataToSend - }; - this.code = 200; - return this; - }) - .catch((error) => new ErrorHandler(error).process()); - } -} - -router.on('register', (routerInst, enableDebug) => { - // Only enable endpoints if controls.Debug=true - if (enableDebug) { - routerInst.register('POST', '/eventListener/:eventListener', EventListenerEndpointHandler); - routerInst.register('POST', '/namespace/:namespace/eventListener/:eventListener', EventListenerEndpointHandler); - } -}); - -module.exports = EventListenerEndpointHandler; diff --git a/src/lib/requestHandlers/httpErrors.js b/src/lib/requestHandlers/httpErrors.js deleted file mode 100644 index 9d2b202e..00000000 --- a/src/lib/requestHandlers/httpErrors.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -class HttpError extends Error { - getCode() { - throw new Error('Method "getCode" not implemented'); - } - - getBody() { - throw new Error('Method "getBody" not implemented'); - } -} - -class BadURLError extends HttpError { - constructor(pathName) { - super(); - this.pathName = pathName; - } - - getCode() { - return 400; - } - - getBody() { - return `Bad URL: ${this.pathName}`; - } -} - -class InternalServerError extends HttpError { - getCode() { - return 500; - } - - getBody() { - return { - code: this.getCode(), - message: 'Internal Server Error' - }; - } -} - -class MethodNotAllowedError extends HttpError { - constructor(allowedMethods) { - super(); - this.allowedMethods = allowedMethods; - } - - getCode() { - return 405; - } - - getBody() { - return { - code: this.getCode(), - message: 'Method Not Allowed', - allow: this.allowedMethods - }; - } -} - -class ServiceUnavailableError extends HttpError { - getCode() { - return 503; - } - - getBody() { - return { - code: this.getCode(), - message: 'Service Unavailable' - }; - } -} - -class UnsupportedMediaTypeError extends HttpError { - getCode() { - return 415; - } - - getBody() { - return { - code: this.getCode(), - message: 'Unsupported Media Type', - accept: ['application/json'] - }; - } -} - -module.exports = { - HttpError, - BadURLError, - InternalServerError, - MethodNotAllowedError, - ServiceUnavailableError, - UnsupportedMediaTypeError -}; diff --git a/src/lib/requestHandlers/ihealthPollerHandler.js b/src/lib/requestHandlers/ihealthPollerHandler.js deleted file mode 100644 index 933f64c3..00000000 --- a/src/lib/requestHandlers/ihealthPollerHandler.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const ErrorHandler = require('./errorHandler'); -const ihealth = require('../ihealth'); -const router = require('./router'); - -/** - * Request handler for /ihealthpoller endpoint - */ -class IHealthPollerEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of IHealthPollerEndpointHandler - * once request processed - */ - process() { - let responsePromise = Promise.resolve(); - if (!this.params.system) { - responsePromise = responsePromise.then(() => new Promise( - (resolve) => { resolve(ihealth.getCurrentState(this.params.namespace)); } - ) - .then((statuses) => { - this.code = 200; - this.body = { - code: this.code, - message: statuses - }; - return this; - })); - } else { - responsePromise = responsePromise.then(() => ihealth.startPoller(this.params.system, this.params.namespace) - .then((state) => { - this.code = state.isRunning ? 202 : 201; - this.body = { - code: this.code, - state - }; - return this; - })); - } - return responsePromise.catch((error) => new ErrorHandler(error).process()); - } -} - -router.on('register', (routerInst, enableDebug) => { - if (enableDebug) { - routerInst.register('GET', '/ihealthpoller', IHealthPollerEndpointHandler); - routerInst.register('GET', '/ihealthpoller/:system', IHealthPollerEndpointHandler); - routerInst.register('GET', '/namespace/:namespace/ihealthpoller', IHealthPollerEndpointHandler); - routerInst.register('GET', '/namespace/:namespace/ihealthpoller/:system', IHealthPollerEndpointHandler); - } -}); - -module.exports = IHealthPollerEndpointHandler; diff --git a/src/lib/requestHandlers/pullConsumerHandler.js b/src/lib/requestHandlers/pullConsumerHandler.js deleted file mode 100644 index 3c0e9626..00000000 --- a/src/lib/requestHandlers/pullConsumerHandler.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const ErrorHandler = require('./errorHandler'); -const pullConsumers = require('../pullConsumers'); -const router = require('./router'); - -/** - * Request handler for /pullconsumer endpoint - */ -class PullConsumerEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Get HTTP Content-Type - * - * @returns {String} HTTP Content-Type header value - */ - getContentType() { - return this.contentType; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of PullConsumerEndpointHandler - * once request processed - */ - process() { - return pullConsumers.getData(this.params.consumer, this.params.namespace) - .then((response) => { - this.code = 200; - this.body = response.data; - this.contentType = response.contentType || undefined; // If not set, default to iControlRest response - return this; - }).catch((error) => new ErrorHandler(error).process()); - } -} - -router.on('register', (routerInst) => { - routerInst.register('GET', '/pullconsumer/:consumer', PullConsumerEndpointHandler); - routerInst.register('GET', '/namespace/:namespace/pullconsumer/:consumer', PullConsumerEndpointHandler); -}); - -module.exports = PullConsumerEndpointHandler; diff --git a/src/lib/requestHandlers/router.js b/src/lib/requestHandlers/router.js deleted file mode 100644 index a4eccded..00000000 --- a/src/lib/requestHandlers/router.js +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const TinyRequestRouter = require('tiny-request-router').Router; - -const configUtil = require('../utils/config'); -const configWorker = require('../config'); -const ErrorHandler = require('./errorHandler'); -const httpErrors = require('./httpErrors'); -const logger = require('../logger'); -const SafeEventEmitter = require('../utils/eventEmitter'); - -/** - * Simple router to route incoming requests to REST API. - * - * @class - * - * @property {Object} pathToMethod - mapping /path/to/resource to handler - */ -class RequestRouter extends SafeEventEmitter { - constructor() { - super(); - this.router = new TinyRequestRouter(); - this.pathToMethod = {}; - } - - /** - * Process request. - * - * @public - * @param {Object} restOperation - request object - * @param {String} [uriPrefix] - prefix to remove from URI before processing - * - * @returns {Promise} resolved once request processed - */ - processRestOperation(restOperation, uriPrefix) { - let responsePromise; - try { - responsePromise = processRestOperation.call(this, restOperation, uriPrefix); - } catch (err) { - // in case if synchronous part of the code failed - logger.exception('restOperation processing error', err); - responsePromise = (new ErrorHandler(new httpErrors.InternalServerError())).process(); - } - return responsePromise.catch((err) => { - logger.exception('restOperation processing error', err); - return (new ErrorHandler(new httpErrors.InternalServerError())).process(); - }) - .then((handler) => { - logger.info(`${handler.getCode()} ${restOperation.getMethod().toUpperCase()} ${restOperation.getUri().pathname}`); - restOperationResponder.call( - this, - restOperation, - handler.getCode(), - handler.getBody(), - handler.getContentType() - ); - }) - .catch((fatalError) => { - // in case if .then above failed - logger.exception('restOperation processing fatal error', fatalError); - restOperationResponder.call(this, restOperation, 500, 'Internal Server Error'); - }); - } - - /** - * Register request handler. - * - * @public - * @param {String | Array} method - HTTP method (POST, GET, etc.), could be array - * @param {String} endpointURI - URI path (see path-to-regexp npm module for more info) - * @param {Object} handlerClass - request handler class (BaseRequestHandler as parent class) - */ - register(methods, endpointURI, handlerClass) { - this.pathToMethod[endpointURI] = this.pathToMethod[endpointURI] || {}; - methods = Array.isArray(methods) ? methods : [methods]; - methods.forEach((method) => { - method = method.toUpperCase(); - logger.debug(`Registering handler for endpoint - ${method} ${endpointURI}`); - this.pathToMethod[endpointURI][method] = handlerClass; - }); - this.router.all(endpointURI); - } - - /** - * Register Endpoints - * - * @public - * @param {Boolean} enableDebug - enable debug endpoints - */ - registerAllHandlers(enableDebug) { - this.emit('register', this, enableDebug); - } - - /** - * Remove all registered handlers - * - * @public - */ - removeAllHandlers() { - this.router.routes = []; - this.pathToMethod = {}; - } -} - -/** - * PRIVATE METHODS - */ -/** - * Find handler for request - * - * @this RequestRouter - * @param {Object} restOperation - request object - * @param {String} [uriPrefix] - prefix to remove from URI before processing - * - * @returns {BaseRequestHandler} handler instance - */ -function findRequestHandler(restOperation, uriPrefix) { - // Somehow we need to respond to such requests. - // When Content-Type === application/json then getBody() tries to - // evaluate data as JSON and returns code 500 on failure. - // Don't know how to re-define this behavior. - if (restOperation.getBody() && restOperation.getContentType().toLowerCase() !== 'application/json') { - return new ErrorHandler(new httpErrors.UnsupportedMediaTypeError()); - } - - const requestURI = restOperation.getUri(); - const requestPathname = requestURI.pathname; - const requestMethod = restOperation.getMethod().toUpperCase(); - // strip prefix if needed - let normalizedPathname = requestPathname; - - if (uriPrefix) { - uriPrefix = uriPrefix.startsWith('/') ? uriPrefix : `/${uriPrefix}`; - if (normalizedPathname.startsWith(uriPrefix)) { - normalizedPathname = normalizedPathname.slice(uriPrefix.length); - } - } - const match = this.router.match(requestMethod, normalizedPathname); - if (!match) { - return new ErrorHandler(new httpErrors.BadURLError(requestPathname)); - } - const RequestHandler = this.pathToMethod[match.path][requestMethod]; - if (!RequestHandler) { - const allowed = Object.keys(this.pathToMethod[match.path]); - allowed.sort(); - return new ErrorHandler(new httpErrors.MethodNotAllowedError(allowed)); - } - - const handler = new RequestHandler(restOperation, match.params); - return handler; -} - -/** - * Process request. - * - * @this RequestRouter - * @param {Object} restOperation - request object - * @param {String} [uriPrefix] - prefix to remove from URI before processing - * - * @returns {Promise} resolved once request processed - */ -function processRestOperation(restOperation, uriPrefix) { - const requestURI = restOperation.getUri(); - const requestPathname = requestURI.pathname; - const requestMethod = restOperation.getMethod().toUpperCase(); - logger.info(`Request received: ${requestMethod} ${requestPathname}`); - - const handler = findRequestHandler.call(this, restOperation, uriPrefix); - logger.verbose(`'${handler.constructor.name}' request handler assigned to request: ${requestMethod} ${requestPathname}`); - return handler.process(); -} - -/** - * LX rest operation responder - * - * @this RequestRouter - * @param {Object} restOperation - restOperation to complete - * @param {String} status - HTTP status - * @param {String} body - HTTP body - * @param {String} [contentType] - HTTP Content-Type Header value - */ -function restOperationResponder(restOperation, status, body, contentType) { - restOperation.setStatusCode(status); - restOperation.setBody(body); - if (contentType) { - restOperation.setContentType(contentType); - } - restOperation.complete(); -} - -const defaultRouter = new RequestRouter(); -configWorker.on('change', (config) => new Promise((resolve) => { - logger.debug('configWorker change event in RequestRouter'); // helpful debug - defaultRouter.removeAllHandlers(); - defaultRouter.registerAllHandlers(configUtil.getTelemetryControls(config).debug); - resolve(); -})); - -module.exports = defaultRouter; diff --git a/src/lib/requestHandlers/systemPollerHandler.js b/src/lib/requestHandlers/systemPollerHandler.js deleted file mode 100644 index 228ad901..00000000 --- a/src/lib/requestHandlers/systemPollerHandler.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BaseRequestHandler = require('./baseHandler'); -const ErrorHandler = require('./errorHandler'); -const router = require('./router'); -const systemPoller = require('../systemPoller'); - -/** - * Request handler for /systempoller endpoint - */ -class SystemPollerEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of SystemPollerEndpointHandler - * once request processed - */ - process() { - return systemPoller.getPollersConfig(this.params.system, { - pollerName: this.params.poller, - namespace: this.params.namespace, - includeDisabled: true - }) - .then(systemPoller.fetchPollersData.bind(systemPoller)) - .then((fetchedData) => { - this.code = 200; - this.body = fetchedData.map((d) => d.data); - return this; - }) - .catch((error) => new ErrorHandler(error).process()); - } -} - -router.on('register', (routerInst, enableDebug) => { - if (enableDebug) { - routerInst.register('GET', '/systempoller/:system/:poller?', SystemPollerEndpointHandler); - routerInst.register('GET', '/namespace/:namespace/systempoller/:system/:poller?', SystemPollerEndpointHandler); - } -}); - -module.exports = SystemPollerEndpointHandler; diff --git a/src/lib/resourceMonitor/index.js b/src/lib/resourceMonitor/index.js index c6f7653d..e3f979ec 100644 --- a/src/lib/resourceMonitor/index.js +++ b/src/lib/resourceMonitor/index.js @@ -1,9 +1,17 @@ -/* - * Copyright 2022. F5 Networks, Inc. See End User License Agreement ("EULA") for - * license terms. Notwithstanding anything to the contrary in the EULA, Licensee - * may copy and modify this software product for its internal business purposes. - * Further, Licensee may upload, publish and distribute the modified version of - * the software product on devcentral.f5.com. +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ 'use strict'; @@ -13,47 +21,39 @@ const APP_THRESHOLDS = require('../constants').APP_THRESHOLDS; const configUtil = require('../utils/config'); -const logger = require('../logger').getChild('resourceMonitor'); const miscUtil = require('../utils/misc'); const rmUtil = require('./utils'); const MemoryMonitor = require('./memoryMonitor'); +const psBuilder = require('./processingState'); const Service = require('../utils/service'); -/** @module resourceMonitor */ +/** + * @module resourceMonitor + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('./memoryMonitor').MemoryCheckStatus} MemoryCheckStatus + * @typedef {import('./processingState').ProcessingState} ProcessingState + */ class ServiceError extends Error {} -const MEM_MON_STOP_EVT = 'memoryMonitorStop'; +const EE_NAMESPACE = 'resmon'; /** * Resource Monitor Class * - * @property {logger.Logger} logger + * NOTE: + * instance will restore its configuration when destroy -> start/restart happened + * + * @fires config.applied + * @fires pstate */ -class ResourceMonitor extends Service { +class ResourceMonitorService extends Service { constructor() { super(); - - /** define static read-only props that should not be overriden */ - Object.defineProperties(this, { - logger: { - value: logger.getChild(this.constructor.name) - } - }); - - this._memoryMonitorState = { - config: {}, - enabled: false, - instance: null, - logging: { - freq: APP_THRESHOLDS.MEMORY.DEFAULT_LOG_FREQ, // logging requence (in ms.) - lastMessage: 0, // last logged message timestamp (in ms.) - level: APP_THRESHOLDS.MEMORY.DEFAULT_LOG_LEVEL - }, - recentUsage: null - }; - this.restartsEnabled = true; + this._memoryMonitorState = initialMemoryMonitorState(); } /** @returns {boolean} true if Memory Monitor is running */ @@ -71,7 +71,7 @@ class ResourceMonitor extends Service { }; } - /** @returns {memoryMonitor.MemoryCheckStatus} most recent data about memory usage */ + /** @returns {MemoryCheckStatus} most recent data about memory usage */ get memoryState() { // making a copy ensures that any other piece of code to be able to modify this data return miscUtil.deepCopy(this._memoryMonitorState.recentUsage); @@ -80,174 +80,163 @@ class ResourceMonitor extends Service { /** * Configure and start the service * + * @fires pstate + * * @param {function} onFatalError - function to call on fatal error to restart the service + * @param {object} info - additional info + * @param {boolean} info.coldStart - true if the service was started first time after creating or once destroyed + * @param {boolean} info.restart - true if the service was started due calling `.restart()`. + * NOTE: set to `false` on cold start. */ - _onStart() { - return new Promise((resolve, reject) => { + _onStart(onFatalError, info) { + return (new Promise((resolve, reject) => { const memState = this._memoryMonitorState; if (memState.instance) { reject(new ServiceError('_memoryMonitorState.instance exists already!')); } else if (memState.enabled) { - this.logger.verbose('_onStart: Memory Monitor enabled, starting...'); + this.logger.debug('_onStart: Memory Monitor enabled, starting...'); memState.instance = new MemoryMonitor( memoryMonitorCb.bind(this), Object.assign(miscUtil.deepCopy(memState.config), { - logger: this.logger.getChild('Memory Monitor') + logger: this.logger }) ); memState.instance.start() .then(resolve, reject); } else { memState.recentUsage = null; - this.logger.verbose('_onStart: Memory Monitor disabled!'); + this.logger.debug('_onStart: Memory Monitor disabled!'); resolve(); } - }); + })) + // emit only once on cold start (application start) + .then(() => info.coldStart && this.ee.safeEmitAsync( + 'pstate', + (onEnable, onDisable) => psBuilder(this, onEnable, onDisable) + )); } /** * Stop the service * - * @param {boolean} [restart] - true if service going to be restarted + * @param {object} info - additional info + * @param {boolean} info.destroy - true if the service was stopped due calling `.destroy()`. + * @param {boolean} info.restart - true if the service was started due calling `.restart()`. */ - _onStop(restart) { - return new Promise((resolve, reject) => { - const memState = this._memoryMonitorState; - if (memState.instance) { - this.logger.verbose( + _onStop(info) { + const memState = this._memoryMonitorState; + let stopRet; + + return Promise.resolve() + .then(() => { + const memMon = memState.instance; + if (memMon === null) { + return Promise.resolve(); + } + + memState.instance = null; + if (info.destroy) { + this.logger.debug('Destroying Memory Monitor.'); + return memMon.destroy(); + } + + this.logger.debug( '_onStop: ' - + ((restart && memState.enabled) + + ((info.restart && memState.enabled) ? 'Restarting Memory Monitor to apply configuration.' : 'Stopping Memory Monitor.') ); - memState.instance.stop() - .then(() => { - memState.instance = null; - if (!memState.enabled) { - memState.recentUsage = null; - this.ee.safeEmit(MEM_MON_STOP_EVT); - } - }) - .then(resolve, reject); - } else { - resolve(); - } - }); + return memMon.stop(); + }) + .then((success) => ({ success }), (error) => ({ error })) + .then((ret) => { + stopRet = ret; + + if (info.destroy || !memState.enabled) { + // clear recent usage stats when monitor disabled + memState.recentUsage = null; + } // otherwise keep recent usage stats to provide seamless service + + if (info.destroy) { + return this.ee.safeEmitAsync('pstate.destroy'); + } + if (!memState.enabled) { + // memory monitor disabled, need to re-enable processing (default state) + this.logger.warning('Re-enabling processing (memory monitor disabled).'); + return this.ee.safeEmitAsync(APP_THRESHOLDS.MEMORY.STATE.OK, null); + } + // otherwise monitor will be restarted, keep current state + return Promise.resolve(); + }) + .then(() => ( + stopRet.error + ? Promise.reject(stopRet.error) + : Promise.resolve(stopRet.success))); } /** @returns {Promise} resolved with true when service destroyed or if it was destroyed already */ destroy() { - // disabled Memory Monitor to emit `MEM_MON_STOP_EVT` later - this._memoryMonitorState.enabled = false; this._offConfigUpdates && this._offConfigUpdates.off() && (this._offConfigUpdates = null); return super.destroy() - .then(() => { + .then((ret) => { + this._memoryMonitorState = initialMemoryMonitorState(); + // all listeners notified already, safe to remove + this._offMyEvents + && this._offMyEvents.off() + && (this._offMyEvents = null); + + // free all pstate refts this.ee.removeAllListeners(APP_THRESHOLDS.MEMORY.STATE.NOT_OK); this.ee.removeAllListeners(APP_THRESHOLDS.MEMORY.STATE.OK); - this.ee.removeAllListeners(MEM_MON_STOP_EVT); - this.logger.info('Destroyed! Data processing enabled!'); + this.ee.removeAllListeners('pstate.destroy'); + + this.logger.warning('Destroyed! Data processing enabled!'); + + return ret; }); } - /** @param {restWorker.ApplicationContext} appCtx - application context */ - initialize(appCtx) { - if (appCtx.configMgr) { - this._offConfigUpdates = appCtx.configMgr.on('change', onConfigEvent.bind(this), { objectify: true }); - this.logger.debug('Subscribed to configuration updates.'); - } else { - this.logger.warning('Unable to subscribe to configuration updates!'); - } - } + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + this._offConfigUpdates = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); - /** - * @param {function} onEnable - * @param {function} onDisable - * - * @returns {ProcessingState} instance - */ - initializePState(onEnable, onDisable) { - return (new ProcessingState(this)).initialize(onEnable, onDisable); + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'config.applied': 'config.applied' }, + { pstate: 'pstate' } + ]); } /** @return {boolean} true if processing allowed by most recent memory status check */ isProcessingEnabled() { const recentUsage = this._memoryMonitorState.recentUsage; - return recentUsage - ? recentUsage.thresholdStatus === APP_THRESHOLDS.MEMORY.STATE.OK - : true; + return recentUsage === null || recentUsage.thresholdStatus === APP_THRESHOLDS.MEMORY.STATE.OK; } } -/** Processing State Class */ -class ProcessingState { - /** @param {ResourceMonitor} resourceMonitor */ - constructor(resourceMonitor) { - this._enabled = true; - this._listeners = []; - this._onDisable = null; - this._onEnable = null; - this._resMonitor = resourceMonitor; - } - - /** @returns {boolean} if processing allowed to continue */ - get enabled() { - return this._enabled; - } - - /** @returns {memoryMonitor.MemoryCheckStatus} most recent data about memory usage */ - get memoryState() { - return this._resMonitor.memoryState; - } - - /** Destroy instance and unsubscribe from all events */ - destroy() { - this._enabled = true; - this._listeners.forEach((listener) => listener.off()); - this._listeners.length = 0; - this._resMonitor = null; - } - - /** - * @param {function} onEnable - * @param {function} onDisable - * - * @returns {ProcessingState} instance - */ - initialize(onEnable, onDisable) { - // save prev state - const isEnabled = this._enabled; - const resMonitor = this._resMonitor; - - this.destroy(); - - // restore prev state - this._enabled = isEnabled; - this._resMonitor = resMonitor; - - // assign callbacks and subscribe to events - this._onEnable = onEnable; - this._onDisable = onDisable; - - const updateEventCb = updateProcessingState.bind(this, true); - this._listeners = [ - APP_THRESHOLDS.MEMORY.STATE.NOT_OK, - APP_THRESHOLDS.MEMORY.STATE.OK, - MEM_MON_STOP_EVT - ].map((evt) => this._resMonitor.ee.on(evt, updateEventCb, { objectify: true })); - - updateProcessingState.call(this, false); - return this; - } +/** @returns {object} initial Memory Monitor state */ +function initialMemoryMonitorState() { + return { + config: {}, + enabled: false, + instance: null, + logging: { + freq: APP_THRESHOLDS.MEMORY.DEFAULT_LOG_FREQ, // logging requence (in ms.) + lastMessage: 0, // last logged message timestamp (in ms.) + level: APP_THRESHOLDS.MEMORY.DEFAULT_LOG_LEVEL + }, + recentUsage: null + }; } /** - * @this ResourceMonitor + * @this ResourceMonitorService * - * @param {memoryMonitor.MemoryCheckStatus} checkStatus + * @param {MemoryCheckStatus} checkStatus */ function memoryMonitorCb(checkStatus) { const memState = this._memoryMonitorState; @@ -298,32 +287,10 @@ function memoryMonitorCb(checkStatus) { this.ee.safeEmit('memoryCheckStatus', checkStatus); } -/** - * Event handler for memore usage state updates - * - * @this ProcessingState - * - * @param {boolean} fireCallbacks - if true then callbacks will be fired - */ -function updateProcessingState(fireCallbacks) { - const prevEnabled = this._enabled; - this._enabled = this._resMonitor.isProcessingEnabled(); - - if (arguments.length === 1) { - // monitor stopped, re-enable all - !prevEnabled && this._onEnable && this._onEnable(); - } else if (fireCallbacks && (prevEnabled !== this._enabled)) { - // call all callbacks in same event loop - this.enabled - ? (this._onEnable && this._onEnable()) - : (this._onDisable && this._onDisable()); - } -} - /** * Create a Memory Monitor configuration * - * @this ResourceMonitor + * @this ResourceMonitorService * * @param {Configuration} config */ @@ -402,26 +369,26 @@ function updateMemoryMonitorConfig(config) { } /** - * @this ResourceMonitor + * @this ResourceMonitorService * * @param {Configuration} config - * - * @returns {Promise} resolved once config applied to the instance */ function onConfigEvent(config) { - return Promise.resolve() + Promise.resolve() .then(() => { - this.logger.verbose('Config "change" event'); + this.logger.debug('Config "change" event'); return setConfig.call(this, config); }).catch((err) => { this.logger.exception('Error caught on attempt to apply configuration to Resource Monitor:', err); - }); + }) + // emit in any case to show we are done with config processing + .then(() => this.ee.safeEmitAsync('config.applied')); } /** * Upate Resource Monitor configuration * - * @this ResourceMonitor + * @this ResourceMonitorService * * @param {Configuration} config - configuration to apply * @@ -446,7 +413,7 @@ function setConfig(config) { return Promise.resolve(needRestart && this.restart()); } -module.exports = ResourceMonitor; +module.exports = ResourceMonitorService; /** * @typedef MemoryMontorLiveConfig @@ -455,3 +422,16 @@ module.exports = ResourceMonitor; * @property {boolean} enabled - true if Memory Monitor enabled * @property {object} logging - logging config */ +/** + * @callback PStateBuilder + * @param {function} onEnable + * @param {function} onDisable + * + * @returns {ProcessingState} + */ +/** + * @event pstate + * @param {PStateBuilder} getPState - function to create Processing State instance + * + * Event fired only once on service very first start + */ diff --git a/src/lib/resourceMonitor/memoryMonitor.js b/src/lib/resourceMonitor/memoryMonitor.js index 2683c099..466e3cdb 100644 --- a/src/lib/resourceMonitor/memoryMonitor.js +++ b/src/lib/resourceMonitor/memoryMonitor.js @@ -1,9 +1,17 @@ -/* - * Copyright 2022. F5 Networks, Inc. See End User License Agreement ("EULA") for - * license terms. Notwithstanding anything to the contrary in the EULA, Licensee - * may copy and modify this software product for its internal business purposes. - * Further, Licensee may upload, publish and distribute the modified version of - * the software product on devcentral.f5.com. +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ 'use strict'; @@ -12,13 +20,16 @@ const APP_THRESHOLDS = require('../constants').APP_THRESHOLDS; const hrtimestamp = require('../utils/datetime').hrtimestamp; -const logger = require('../logger').getChild('memoryMonitor'); const miscUtil = require('../utils/misc'); const rmUtil = require('./utils'); const Service = require('../utils/service'); const timers = require('../utils/timers'); -/** @module resourceMonitor/memoryMonitor */ +/** + * @private + * + * @module resourceMonitor/memoryMonitor + */ class ServiceError extends Error {} @@ -48,44 +59,39 @@ class ServiceError extends Error {} * * @property {number} freeMemoryLimit - OS free memory limit (in MB) * @property {boolean} gcEnabled - true if GC enabled otherwise false - * @property {Logger} logger - logger * @property {number} provisioned - max number of MB available for the process - * (can be configured via --max_old_space_size CLI option) + * (can be configured via --max-old-space-size CLI option) * @property {number} releaseThreshold - amount of memory to release threshold lock (in MB) * @property {number} releasePercent - amount of memory to release threshold lock (in %) * @property {number} threshold - V8's RSS usage threshold (in MB) * @property {number} thresholdPercent - V8's RSS usage threshold (in %) */ -class MemoryMonitor extends Service { +class MemoryMonitorService extends Service { /** * Constructor * * @param {function(MemoryCheckStatus)} cb - callback * @param {object} [options] - options * @param {number} [options.freeMemoryLimit] - OS free memory limit (in MB) - * @param {object} [options.fs] - FS module, by default 'fs' from './misc' * @param {Interval[]} [options.intervals] - memory check intervals * @param {Logger} [options.logger] - logger * @param {number} [options.provisioned] - amount of provisioned memory in MB * @param {number} [options.thresholdPercent] - application memory threshold percent to use for alerts */ constructor(cb, options) { - super(); - options = Object.assign({ freeMemoryLimit: APP_THRESHOLDS.MEMORY.DEFAULT_MIN_FREE_MEM, - fs: miscUtil.fs, intervals: miscUtil.deepCopy(APP_THRESHOLDS.MEMORY.DEFAULT_CHECK_INTERVALS), - logger: logger.getChild(this.constructor.name), provisioned: miscUtil.getRuntimeInfo().maxHeapSize, releasePercent: APP_THRESHOLDS.MEMORY.DEFAULT_RELEASE_PERCENT, thresholdPercent: APP_THRESHOLDS.MEMORY.DEFAULT_LIMIT_PERCENT }, options || {}); + super(options.logger); + this._lastKnownIntervalIdx = -1; this._lastKnownState = APP_THRESHOLDS.MEMORY.STATE.OK; this._timerPromise = Promise.resolve(); - this.restartsEnabled = true; /** define static read-only props that should not be overriden */ Object.defineProperties(this, { @@ -99,7 +105,7 @@ class MemoryMonitor extends Service { value: miscUtil.deepFreeze(enrichMemoryCheckIntervals(options.intervals)) }, _readOSFreeMem: { - value: () => options.fs.readFileSync('/proc/meminfo') + value: () => miscUtil.fs.readFileSync('/proc/meminfo') }, freeMemoryLimit: { value: options.freeMemoryLimit @@ -107,9 +113,6 @@ class MemoryMonitor extends Service { gcEnabled: { value: typeof global.gc === 'function' }, - logger: { - value: options.logger - }, provisioned: { value: options.provisioned }, @@ -248,7 +251,7 @@ function enrichMemoryCheckIntervals(intervals) { /** * Get interval index according to memory usage * - * @this MemoryMonitor + * @this MemoryMonitorService * * @param {number} usagePercent - memory usage percent * @@ -263,7 +266,7 @@ function getIntervalIdx(usagePercent) { /** * Perform memory check * - * @this MemoryMonitor + * @this MemoryMonitorService * * @param {timers.BasicTimer} timer - origin time (used to verify that timer is still active) * @@ -326,7 +329,7 @@ function memoryMonitorCheck(timer) { /** * Set new interval for the timer * - * @this MemoryMonitor + * @this MemoryMonitorService * * @param {number} interval - interval in seconds * @param {timers.BasicTimer} timer - origin time (used to verify that timer is still active) @@ -340,7 +343,7 @@ function updateTimerInterval(interval, timer) { () => timer === this._timer && this._timer .update(memoryMonitorCheck.bind(this, this._timer), interval) - .then(() => this.logger.info(`Interval updated to ${interval}s.`)), + .then(() => this.logger.debug(`Interval updated to ${interval}s.`)), (err) => this.logger.exception('Uncaught error on attempt to update interval:', err) ); return this._timerPromise; @@ -349,7 +352,7 @@ function updateTimerInterval(interval, timer) { /** * Application's memory usage stats * - * @this MemoryMonitor + * @this MemoryMonitorService * * @returns {MemoryUsage} */ @@ -393,7 +396,7 @@ function getOverallUtilization(usage) { : usage.thresholdUtilzationPercent; } -module.exports = MemoryMonitor; +module.exports = MemoryMonitorService; /** * @typedef InternalInterval diff --git a/src/lib/resourceMonitor/processingState.js b/src/lib/resourceMonitor/processingState.js new file mode 100644 index 00000000..27147433 --- /dev/null +++ b/src/lib/resourceMonitor/processingState.js @@ -0,0 +1,185 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const APP_THRESHOLDS = require('../constants').APP_THRESHOLDS; + +/** + * @private + * + * @module resourceMonitor/processingState + * + * @typedef {import('eventemitter2').Listener} Listener + * @typedef {import('./memoryMonitor').MemoryCheckStatus} MemoryCheckStatus + * @typedef {import('./index').ResourceMonitor} ResourceMonitor + */ + +/** + * Event handler for memore usage state updates + * + * @this ProcessingStateCtx + * + * @param {boolean} forceCallback - true if callbacks need to be called + * @param {undefined | MemoryCheckStatus} [memoryStatus] - current memory state info. + * Is `undefined` on init or when destroyed. + */ +function updateProcessingState(forceCallback, memoryStatus) { + if (this.resMonitor === null) { + // destroyed already, ignore + return; + } + + const prevEnabled = this.enabled; + let cb = null; + + // update state + this.enabled = this.resMonitor.isProcessingEnabled(); + + if (forceCallback) { + cb = this.enabled ? this.onEnable : this.onDisable; + } else if (!memoryStatus) { + // monitor stopped, re-enable all if not yet + cb = prevEnabled ? null : this.onEnable; + } else if (prevEnabled !== this.enabled) { + cb = this.enabled ? this.onEnable : this.onDisable; + } + + cb && setImmediate(cb); +} + +/** + * @param {ResourceMonitor} resMonitor - Resource Monitor instance + * @param {function} onEnable - callback to call when processing disabled + * @param {function} onDisable - callback to call when processing disabled + * + * @returns {ProcessingStateCtx} + */ +function makeProcessingStateCtx(resMonitor, onEnable, onDisable) { + return { + enabled: true, + listeners: [], + onDisable, + onEnable, + resMonitor + }; +} + +/** + * @param {ProcessingStateCtx} ctx - context + * + * @returns {ProcessingState} processing state handler + */ +function buildProcessingStateHandler(ctx) { + /** + * - create anonymous structure to keep fields private + * - ideally `receiver` should not retain access to `instance` + * because `updateProcessingState` retains `ctx` with all properties + * and `updateProcessingState` is retained by resMonitor.ee as event listener. + * Once resMonitor.ee. removed all listeners then refs will be freed. + */ + + const instance = {}; + /** define static read-only props that should not be overriden */ + Object.defineProperties(instance, { + destroy: { + value: () => { + if (instance.destroyed) { + return; + } + + // shallow copy to be able to remove instance properly + const ctxCopy = Object.assign({}, ctx); + + ctx.enabled = true; + ctx.listeners.forEach((listener) => listener.off()); + ctx.listeners.length = 0; + ctx.onDisable = null; + ctx.onEnable = null; + ctx.resMonitor = null; + + updateProcessingState.call(ctxCopy, false); + } + }, + destroyed: { + get() { + return ctx.resMonitor === null; + } + }, + enabled: { + get() { + return ctx.enabled; + } + }, + memoryState: { + get() { + return ctx.resMonitor.memoryState; + } + } + }); + + const updateEventCb = updateProcessingState.bind(ctx, false); + ctx.listeners = [ + APP_THRESHOLDS.MEMORY.STATE.NOT_OK, + APP_THRESHOLDS.MEMORY.STATE.OK + ].map((evt) => ctx.resMonitor.ee.on(evt, updateEventCb, { objectify: true })); + + ctx.listeners.push( + ctx.resMonitor.ee.on( + 'pstate.destroy', + instance.destroy, + { objectify: true } + ) + ); + + updateProcessingState.call(ctx, true); + return instance; +} + +/** + * Build Processing State instance + * + * @param {ResourceMonitor} resMonitor + * @param {function} onEnable + * @param {function} onDisable + * + * @returns {ProcessingState} + */ +module.exports = function builder(resMonitor, onEnable, onDisable) { + return buildProcessingStateHandler( + makeProcessingStateCtx(resMonitor, onEnable, onDisable) + ); +}; + +/** + * @typedef ProcessingState + * @type {Object} + * @property {function} destroy - destroy instance + * @property {boolean} destroyed - true if processing state instance was detroyed + * @property {boolean} enabled - if processing allowed to continue + * @property {MemoryCheckStatus} memoryState - most recent data about memory usage + */ +/** + * @typedef ProcessingStateCtx + * @type {object} + * @property {boolean} enabled - current state + * @property {Listener[]} listeners - events listeners + * @property {function} onDisable - callback to call when processing disabled + * @property {function} onEnable - callback to call when processing disabled + * @property {ResourceMonitor} resMonitor - Resource Monitor instance + */ diff --git a/src/lib/resourceMonitor/utils.js b/src/lib/resourceMonitor/utils.js index f00f9473..fd5dbe5f 100644 --- a/src/lib/resourceMonitor/utils.js +++ b/src/lib/resourceMonitor/utils.js @@ -1,15 +1,29 @@ -/* - * Copyright 2022. F5 Networks, Inc. See End User License Agreement ("EULA") for - * license terms. Notwithstanding anything to the contrary in the EULA, Licensee - * may copy and modify this software product for its internal business purposes. - * Further, Licensee may upload, publish and distribute the modified version of - * the software product on devcentral.f5.com. +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ 'use strict'; /* eslint-disable no-restricted-properties, no-use-before-define */ +/** + * @private + * + * @module resourceMonitor/utils + */ + /** @type {integer} */ const BYTES_TO_MB_DIVISOR = Math.pow(1024, 2); diff --git a/src/lib/requestHandlers/connections.js b/src/lib/restAPI/contentTypes.js similarity index 75% rename from src/lib/requestHandlers/connections.js rename to src/lib/restAPI/contentTypes.js index fb709736..d57e4720 100644 --- a/src/lib/requestHandlers/connections.js +++ b/src/lib/restAPI/contentTypes.js @@ -16,9 +16,6 @@ 'use strict'; -require('./declareHandler'); -require('./eventListenerHandler'); -require('./infoHandler'); -require('./ihealthPollerHandler'); -require('./pullConsumerHandler'); -require('./systemPollerHandler'); +module.exports = Object.freeze({ + APP_JSON: 'application/json' +}); diff --git a/src/lib/restAPI/errors.js b/src/lib/restAPI/errors.js new file mode 100644 index 00000000..10738358 --- /dev/null +++ b/src/lib/restAPI/errors.js @@ -0,0 +1,129 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const CT_APP_JSON = require('./contentTypes').APP_JSON; + +class HttpError extends Error { + /** + * @param {integer} code - HTTP error code + * @param {string} message - HTTP error message + * @param {any} error - detailed error message + */ + constructor(code, message, error) { + super(); + this.code = code; + this.message = message; + this.error = error; + } + + /** @returns {string | object} error body */ + getBody() { + return { + code: this.getCode(), + error: this.getError(), + message: this.getMessage() + }; + } + + /** @returns {integer} HTTP error code */ + getCode() { + return this.code; + } + + /** @returns {string | undefined} HTTP content type */ + getContentType() { + return CT_APP_JSON; + } + + /** @returns {string | undefined} detailed error message */ + getError() { + return this.error; + } + + /** @returns {string} HTTP error */ + getMessage() { + return this.message; + } +} + +/** + * HTTP 500 Internal Server Error Class + */ +class InternalServerError extends HttpError { + /** @param {any} error - detailed error message */ + constructor(error) { + super(500, 'Internal Server Error', error); + } +} + +/** + * HTTP 405 Method Not Allowed Error Class + */ +class MethodNotAllowedError extends HttpError { + /** @param {string[]} allowed - allowed HTTP methods */ + constructor(allowed) { + super(405, 'Method Not Allowed', `Allowed methods: ${allowed.join(', ')}`); + } +} + +/** + * HTTP 404 Not Found Error Class + */ +class NotFoundError extends HttpError { + /** @param {string} path - invalid path */ + constructor(path) { + super(404, 'Not Found', `Bad URL: ${path}`); + } +} + +/** + * HTTP 503 Service Unavailable Error Class + */ +class ServiceUnavailableError extends HttpError { + /** @param {any} error - detailed error message */ + constructor(error) { + super(503, 'Service Unavailable', error); + } +} + +class UnprocessableEntityError extends HttpError { + /** @param {any} error - detailed error message */ + constructor(error) { + super(422, 'Unprocessable entity', error); + } +} + +/** + * HTTP 415 Unsupported Media Type Error Class + */ +class UnsupportedMediaTypeError extends HttpError { + /** @param {string[]} contentTypes - accepted content types */ + constructor(contentTypes) { + super(415, 'Unsupported Media Type', `Accepted Content-Type: ${contentTypes.join(', ')}`); + } +} + +module.exports = { + HttpError, + InternalServerError, + MethodNotAllowedError, + NotFoundError, + ServiceUnavailableError, + UnprocessableEntityError, + UnsupportedMediaTypeError +}; diff --git a/src/lib/restAPI/handlers/declare.js b/src/lib/restAPI/handlers/declare.js new file mode 100644 index 00000000..0f286d03 --- /dev/null +++ b/src/lib/restAPI/handlers/declare.js @@ -0,0 +1,150 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const configWorker = require('../../config'); +const deepCopy = require('../../utils/misc').deepCopy; +const errors = require('../errors'); +const CT_APP_JSON = require('../contentTypes').APP_JSON; + +/** + * @module restapi/handlers/declare + * + * @typedef {import('../../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../request').Request} Request + * @typedef {import('../index').RequestHandler} RequestHandler + * @typedef {import('../response').Response} Response + */ + +/** + * @implements {RequestHandler} + */ +class DeclareHandler { + constructor() { + this.hasDeclarationInProcess = false; + } + + /** @inheritdoc */ + get name() { + return 'Declare'; + } + + /** @inheritdoc */ + destroy() { + this.hasDeclarationInProcess = false; + } + + /** @inheritdoc */ + handle(req, res) { + if (req.getMethod() === 'GET') { + return getCurrentDeclaration.call(this, req, res); + } + + if (this.hasDeclarationInProcess) { + throw new errors.ServiceUnavailableError('Can\'t process new declaration while previous one is still in progress'); + } + + return applyNewDeclaration.call(this, req, res); + } +} + +/** + * @param {Request} req + * + * @returns {DeclareMetadata} + */ +function makeMetadata(req) { + const metadata = { + message: 'Incoming declaration via REST API', + originDeclaration: req.getBody(), + sourceIP: req.getHeaders()['X-Forwarded-For'] || 'unknown' + }; + const uriParams = req.getUriParams(); + if (uriParams.namespace) { + metadata.namespace = uriParams.namespace; + } + return metadata; +} + +/** + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} resolved once configuration applied/saved + */ +async function applyNewDeclaration(req, res) { + this.hasDeclarationInProcess = true; + const metadata = makeMetadata(req); + + try { + sendResponse(res, await (typeof metadata.namespace === 'undefined' + ? configWorker.processDeclaration(deepCopy(metadata.originDeclaration), { metadata }) + : configWorker.processNamespaceDeclaration( + deepCopy(metadata.originDeclaration), + metadata.namespace, + { metadata } + ))); + } finally { + this.hasDeclarationInProcess = false; + } +} + +/** + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} resolved once current configuration written to the response object + */ +async function getCurrentDeclaration(req, res) { + const declaration = await configWorker.getDeclaration(req.getUriParams().namespace); + sendResponse(res, declaration); +} + +/** + * @param {Response} res + * @param {object} declaration + */ +function sendResponse(res, declaration) { + res.body = { + message: 'success', + declaration + }; + res.code = 200; + res.contentType = CT_APP_JSON; +} + +module.exports = { + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + appEvents.on('restapi.register', (register) => { + const handler = new DeclareHandler(); + register('GET', '/declare', handler); + register('POST', '/declare', handler); + register('GET', '/namespace/:namespace/declare', handler); + register('POST', '/namespace/:namespace/declare', handler); + }); + } +}; + +/** + * @typedef DeclareMetadata + * @type {object} + * @property {string} message + * @property {string | undefined} namespace + * @property {object} originDeclaration + * @property {string} sourceIP + */ diff --git a/src/lib/restAPI/handlers/error.js b/src/lib/restAPI/handlers/error.js new file mode 100644 index 00000000..f3e63df4 --- /dev/null +++ b/src/lib/restAPI/handlers/error.js @@ -0,0 +1,75 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const errors = require('../../errors'); +const httpErrors = require('../errors'); + +/** + * @module restapi/handlers/error + * + * @typedef {import('../errors').HttpError} HttpError + * @typedef {import('../request').Request} Request + * @typedef {import('../index').RequestHandler} RequestHandler + * @typedef {import('../response').Response} Response + */ + +/** + * @param {Request} req - request + * @param {Error} error - error to process + * + * @returns {HttpError} processedj error + */ +function setHttpEquivalent(req, error) { + if (error instanceof errors.ConfigLookupError) { + error = new httpErrors.NotFoundError(req.getURI()); + } else if (error instanceof errors.ValidationError) { + let msg = error.message; + try { + msg = JSON.parse(msg); + } catch (_) { + // do nothing + } + error = new httpErrors.UnprocessableEntityError(msg); + } else if (!(error instanceof httpErrors.HttpError)) { + error = new httpErrors.InternalServerError(error.message); + } + return error; +} + +/** + * @implements {RequestHandler} + */ +module.exports = Object.freeze({ + /** + * @param {Request} req + * @param {Response} res + * @param {Error} + * + * @returns {Promise} resolved once request processed + */ + handle(req, res, error) { + return Promise.resolve() + .then(() => { + error = setHttpEquivalent(req, error); + res.body = error.getBody(); + res.code = error.getCode(); + res.contentType = error.getContentType(); + }); + }, + name: 'Error' +}); diff --git a/src/lib/restAPI/handlers/eventListener.js b/src/lib/restAPI/handlers/eventListener.js new file mode 100644 index 00000000..49536459 --- /dev/null +++ b/src/lib/restAPI/handlers/eventListener.js @@ -0,0 +1,66 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const CT_APP_JSON = require('../contentTypes').APP_JSON; +const dataPublisher = require('../../eventListener/dataPublisher'); + +// TODO: consider move the module next to eventListener folder/module + +/** + * @module restapi/handlers/eventListener + * + * @typedef {import('../../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../index').RequestHandler} RequestHandler + */ + +/** + * @implements {RequestHandler} + */ +const handler = Object.freeze({ + /** @inheritdoc */ + async handle(req, res) { + const data = req.getBody(); + const uriParams = req.getUriParams(); + + await dataPublisher.sendDataToListener( + data, + uriParams.eventListener, + { namespace: uriParams.namespace } + ); + + res.body = { + data, + message: 'success' + }; + res.code = 200; + res.contentType = CT_APP_JSON; + }, + name: 'Event Listener' +}); + +module.exports = { + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + appEvents.on('restapi.register', (register, config) => { + if (config.debug) { + register('POST', '/eventListener/:eventListener', handler); + register('POST', '/namespace/:namespace/eventListener/:eventListener', handler); + } + }); + } +}; diff --git a/src/lib/restAPI/handlers/index.js b/src/lib/restAPI/handlers/index.js new file mode 100644 index 00000000..6cbbf0c9 --- /dev/null +++ b/src/lib/restAPI/handlers/index.js @@ -0,0 +1,37 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const declare = require('./declare'); +const eventListenerHandler = require('./eventListener'); +const infoHandler = require('./info'); + +// TODO: handlers should reside in designated folders/modules and Services should listen for events to register handlers +// as result the process will be automated instead of manual import like now + +/** + * @module restapi/handlers + * + * @typedef {import('../../appEvents').ApplicationEvents} ApplicationEvents + */ + +/** @param {ApplicationEvents} appEvents - application events */ +module.exports = function initialize(appEvents) { + declare.initialize(appEvents); + eventListenerHandler.initialize(appEvents); + infoHandler.initialize(appEvents); +}; diff --git a/src/lib/requestHandlers/infoHandler.js b/src/lib/restAPI/handlers/info.js similarity index 53% rename from src/lib/requestHandlers/infoHandler.js rename to src/lib/restAPI/handlers/info.js index 60bfe003..de225c9b 100644 --- a/src/lib/requestHandlers/infoHandler.js +++ b/src/lib/restAPI/handlers/info.js @@ -16,40 +16,23 @@ 'use strict'; -const appInfo = require('../appInfo'); -const BaseRequestHandler = require('./baseHandler'); -const router = require('./router'); +const appInfo = require('../../appInfo'); +const CT_APP_JSON = require('../contentTypes').APP_JSON; /** - * Request handler for /info endpoint + * @module restapi/handlers/info + * + * @typedef {import('../../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../index').RequestHandler} RequestHandler */ -class InfoEndpointHandler extends BaseRequestHandler { - /** - * Get response code - * - * @returns {Integer} response code - */ - getCode() { - return this.code; - } - /** - * Get response body - * - * @returns {Any} response body - */ - getBody() { - return this.body; - } - - /** - * Process request - * - * @returns {Promise} resolved with instance of InfoEndpointHandler once request processed - */ - process() { - this.code = 200; - this.body = { +/** + * @implements {RequestHandler} + */ +const handler = Object.freeze({ + /** @inheritdoc */ + handle(req, res) { + res.body = { branch: appInfo.branch, buildID: appInfo.buildID, buildTimestamp: appInfo.timestamp, @@ -60,10 +43,15 @@ class InfoEndpointHandler extends BaseRequestHandler { schemaMinimum: appInfo.schemaVersion.minimum, version: appInfo.version }; - return Promise.resolve(this); - } -} + res.code = 200; + res.contentType = CT_APP_JSON; + }, + name: 'Info' +}); -router.on('register', (routerInst) => routerInst.register('GET', '/info', InfoEndpointHandler)); - -module.exports = InfoEndpointHandler; +module.exports = { + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + appEvents.on('restapi.register', (register) => register('GET', '/info', handler)); + } +}; diff --git a/src/lib/restAPI/index.js b/src/lib/restAPI/index.js new file mode 100644 index 00000000..71936762 --- /dev/null +++ b/src/lib/restAPI/index.js @@ -0,0 +1,293 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const pathUtil = require('path'); + +const assert = require('../utils/assert'); +const configUtil = require('../utils/config'); +const deepFreeze = require('../utils/misc').deepFreeze; +const errorHandler = require('./handlers/error'); +const httpErrors = require('./errors'); +const ModuleLoader = require('../utils/moduleLoader').ModuleLoader; +const Request = require('./request'); +const Response = require('./response'); +const Router = require('./router'); +const Service = require('../utils/service'); +const uuid = require('../utils/misc').generateUuid; + +/** + * @module restapi + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../../nodejs/restWorker').RestOperation} RestOperation + */ + +const EE_NAMESPACE = 'restapi'; +const HANDLERS_DIR = pathUtil.join(__dirname, 'handlers'); + +/** + * REST API Service Class + * + * + * @fires restapi.register + */ +class RESTAPIService extends Service { + /** + * @param {string} [uriPrefix] - URI prefix + * @param {string} [handlers] - directory with request handlers + */ + constructor(uriPrefix = '', handlers = HANDLERS_DIR) { + super(); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + uriPrefix: { + value: uriPrefix + } + }); + this.restartsEnabled = true; + + // TODO: move to _onStart later once configWorker updated + this._config = makeConfig({}); + + // TODO: remove once endpoints updated and moved to separate modules + this.logger.debug(`Loading request handlers from '${handlers}'`); + this._handlers = ModuleLoader.load(handlers); + + assert.exist(this._handlers, `Unable to load handlers from '${handlers}'`); + } + + /** @inheritdoc */ + async _onStart() { + this._registerEvents(); + + this._router = new Router({ + logger: this.logger, + uriPrefix: this.uriPrefix + }); + + // helps to prevent situation when someone is trying to register + // a new endpoint when router was destroyed in _onStop already. + const endpointRegisterHandler = { + router: this._router + }; + this._endpointRegisterHandler = endpointRegisterHandler; + + // emit event and wait till all endpoints registered + await this.ee.safeEmitAsync( + 'register', + (...args) => endpointRegisterHandler.router.register.call( + endpointRegisterHandler.router, + ...args + ), + this._config + ); + + // register request handler + await this.ee.safeEmitAsync( + 'requestHandler.created', + onRequestEvent.bind(this), + (unreg) => { + this._unregRequestHandler = unreg; + } + ); + } + + /** @inheritdoc */ + async _onStop() { + // does not allow to register endpoints anymore + this._endpointRegisterHandler.router = null; + this._endpointRegisterHandler = null; + + // stop receiving requests + this._unregRequestHandler && this._unregRequestHandler(); + this._unregRequestHandler = null; + + // stop receiving config updates + this._configListener.off(); + this._configListener = null; + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + + // remove all registered routes and handlers + await this._router.removeAll(); + this._router = null; + } + + /** @inheritdoc */ + destroy() { + // TODO: remove later once configWorker updated + this._config = makeConfig({}); + return super.destroy(); + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._configListener = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'config.applied': 'config.applied' }, + 'register', + { 'requestHandler.created': 'requestHandler.created' }, + { 'service.started': 'service.started' }, + { 'service.stopped': 'service.stopped' } + ]); + this.logger.debug('Registered public events.'); + }; + + // TODO: remove once endpoints updated and moved to separate modules + this._handlers(appEvents); + } +} + +/** @returns {Config} service configuration */ +function makeConfig(controlsConfig) { + return deepFreeze({ + debug: !!controlsConfig.debug + }); +} + +/** + * @this RESTAPIService + * + * @returns {boolean} true if restart needed to apply a new configuration + */ +function needRestart(newConfig) { + return Object.entries(newConfig) + .some(([key, value]) => this._config[key] !== value); +} + +/** + * @this RESTAPIService + * + * @param {Configuration} config + */ +async function onConfigEvent(config) { + this.logger.debug('Config "change" event'); + + const applyConfig = async () => { + const newConfig = makeConfig(configUtil.getTelemetryControls(config)); + if (needRestart.call(this, newConfig)) { + this._config = newConfig; + return this.restart(); + } + return Promise.resolve(); + }; + + try { + await applyConfig(); + } catch (error) { + this.logger.exception('Error caught on attempt to apply configuration to REST API Service:', error); + } finally { + // - emit in any case to show we are done with config processing + // - do not wait for results + this.ee.safeEmitAsync('config.applied'); + } +} + +/** + * @this RESTAPIService + * + * @param {RestOperation} restOp - get Rest Operation instance + */ +async function onRequestEvent(restOp) { + assert.exist(this._router, 'should have router initialized!'); + + const rid = uuid().slice(0, 5); + const req = new Request(restOp); + this.logger.info(`Request ${rid} received: ${req.getMethod()} ${req.getURI()}`); + + const res = new Response(); + let error; + + try { + const handler = this._router.match(req); + this.logger.verbose(`'${handler.name}' request handler assigned to request ${rid}: ${req.getMethod()} ${req.getURI()}`); + await handler.handle(req, res); + } catch (handlerError) { + error = handlerError; + } + + if (error) { + if (!(error instanceof httpErrors.HttpError)) { + this.logger.exception(`Request ${rid} processing error (${req.getMethod()} ${req.getURI()})`, error); + } + + this.logger.verbose(`'${errorHandler.name}' request handler assigned to request ${rid}: ${req.getMethod()} ${req.getURI()}`); + await errorHandler.handle(req, res, error); + } + + this.logger.info(`Request ${rid} processed: ${res.getCode()} ${req.getMethod()} ${req.getURI()}`); + + restOp.setStatusCode(res.getCode()); + restOp.setBody(res.getBody()); + if (res.getContentType()) { + restOp.setContentType(res.getContentType()); + } + restOp.complete(); +} + +module.exports = RESTAPIService; + +/** + * @typedef Config + * @type {object} + * @property {boolean} debug + */ +/** + * @event restapi.register + * @param {Register} register + * @param {Config} config + */ +/** + * @callback Register + * @param {string | string[]} methods - HTTP methods + * @param {string} endpointURI - URI + * @param {RequestHandler} handler - request handler + * @returns {function} callback to deregister handler + */ +/** + * @interface RequestHandler + * + * @property {function} [destroy] + * @property {function} handle + * @property {string} name + */ +/** + * @function + * @name RequestHandler#handle + * @param {Request} req + * @param {Response} res + * @returns {Promise | undefined} resolved once request processed + */ +/** + * @function + * @name RequestHandler#destroy + * @param {string} method + * @param {string} uri + * @returns {Promise | undefined} resolved once handler destroyed + * + * NOTE: optional to implement + */ diff --git a/src/lib/restAPI/request.js b/src/lib/restAPI/request.js new file mode 100644 index 00000000..1f574b8c --- /dev/null +++ b/src/lib/restAPI/request.js @@ -0,0 +1,77 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const querystring = require('querystring'); + +/** + * Request Class + */ +class Request { + /** + * Constructor + * + * @param {Object} restOperation + * @param {Object} params + */ + constructor(restOperation, params = {}) { + this.restOperation = restOperation; + this.params = params; + } + + /** @returns {any} HTTP request body */ + getBody() { + return this.restOperation.getBody(); + } + + /** @returns {string} HTTP Content-Type header value */ + getContentType() { + return this.restOperation.getContentType().toLowerCase(); + } + + /** @returns {object} HTTP request headers */ + getHeaders() { + return this.restOperation.getHeaders() || {}; + } + + /** @returns {string} HTTP method name converted to upper case */ + getMethod() { + return this.restOperation.getMethod().toUpperCase(); + } + + /** @returns {object} HTTP URI params */ + getUriParams() { + return this.params; + } + + /** @returns {object} HTTP query params */ + getQueryParams() { + if (!this._queryParams) { + const search = this.restOperation.getUri().search; + // ignore leading ? + this._queryParams = search ? querystring.parse(search.slice(1)) : {}; + } + return this._queryParams; + } + + /** @returns {string} HTTP URI */ + getURI() { + return this.restOperation.getUri().pathname; + } +} + +module.exports = Request; diff --git a/src/lib/restAPI/response.js b/src/lib/restAPI/response.js new file mode 100644 index 00000000..93970c8b --- /dev/null +++ b/src/lib/restAPI/response.js @@ -0,0 +1,50 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const NOT_SET = Symbol('not set'); + +/** + * Response Class + */ +class Response { + constructor() { + this.body = NOT_SET; + this.code = NOT_SET; + this.contentType = NOT_SET; + } + + /** @returns {any} response body */ + getBody() { + return this.body === NOT_SET ? undefined : this.body; + } + + /** @returns {number} response code */ + getCode() { + if (this.code === NOT_SET) { + throw new Error('HTTP response code is not set!'); + } + return this.code; + } + + /** @returns {any} response content type */ + getContentType() { + return this.contentType === NOT_SET ? undefined : this.contentType; + } +} + +module.exports = Response; diff --git a/src/lib/restAPI/router.js b/src/lib/restAPI/router.js new file mode 100644 index 00000000..1f0a240a --- /dev/null +++ b/src/lib/restAPI/router.js @@ -0,0 +1,239 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const TinyRouter = require('tiny-request-router').Router; + +const assert = require('../utils/assert'); +const CT_APP_JSON = require('./contentTypes').APP_JSON; +const httpErrors = require('./errors'); + +/** + * @module restapi/router + * + * @typedef {import('../logger').Logger} Logger + * @typedef {import('./errors').MethodNotAllowedError} MethodNotAllowedError + * @typedef {import('./errors').NotFoundError} NotFoundError + * @typedef {import('./request').Request} Request + * @typedef {import('./index').RequestHandler} RequestHandler + * @typedef {import('./errors').UnsupportedMediaTypeError} UnsupportedMediaTypeError + */ + +/** + * Simple router to route incoming requests to REST API Service. + * + * @property {Logger} logger + * @property {string} uriPrefix + */ +class Router { + /** + * @param {object} options - options + * @param {Logger} options.logger - logger + * @param {string} [options.uriPrefix = '/'] - URI prefix + */ + constructor({ logger, uriPrefix }) { + uriPrefix = uriPrefix || '/'; + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + logger: { + value: logger.getChild(this.constructor.name) + }, + uriPrefix: { + value: uriPrefix.startsWith('/') + ? uriPrefix + : `/${uriPrefix}` + } + }); + + // router to match requests + this._router = new TinyRouter(); + // routers to match HTTP methods + this._routes = {}; + // callbacks to destroy handlers + this._destroyHandlers = []; + } + + /** + * NOTE: + * Sets req's `params` property when handler found only! + * + * @param {Request} req - request + * + * @returns {RequestHandler} matched request handler + * + * @throws {NotFoundError} when unable to find a handler for URI + * @throws {MethodNotAllowedError} when handler does not handle HTTP method + * @throws {UnsupportedMediaTypeError} when invalid content-type received + */ + match(req) { + // Somehow we need to respond to such requests. + // When Content-Type === application/json then getBody() tries to + // evaluate data as JSON and returns code 500 on failure. + // Don't know how to re-define this behavior. + if (req.getBody() && req.getContentType() !== CT_APP_JSON) { + throw new httpErrors.UnsupportedMediaTypeError([CT_APP_JSON]); + } + + const method = req.getMethod(); + const uri = req.getURI(); + + const match = this._router.match(method, uri); + if (!(match && this._routes[match.path])) { + throw new httpErrors.NotFoundError(uri); + } + + const handler = this._routes[match.path][method]; + if (!handler) { + throw new httpErrors.MethodNotAllowedError( + Object.keys(this._routes[match.path]).sort() + ); + } + + req.params = match.params; + return handler[0]; + } + + /** + * Register request handler + * + * NOTE: may sliently override existing handlers + * + * @param {string | string[]} method - HTTP method (POST, GET, etc.), could be array + * @param {string} endpointURI - URI path (see path-to-regexp npm module for more info) + * @param {RequestHandler} handler - request handler + * + * @returns {function} callback to call to deregister handler + */ + register(methods, endpointURI, handler) { + methods = Array.isArray(methods) ? methods : [methods]; + + methods.forEach((method) => assert.string(method, 'HTTP method')); + assert.string(endpointURI, 'endpointURI'); + assert.instanceOf(handler.handle, Function, 'handler.handle'); + + // unique symbol ref every time + const uniqueKey = Symbol('unique'); + + if (endpointURI.startsWith('/') && this.uriPrefix.endsWith('/')) { + endpointURI = endpointURI.slice(1); + } + + endpointURI = `${this.uriPrefix}${endpointURI}`; + this._routes[endpointURI] = this._routes[endpointURI] || {}; + + methods.forEach((method) => { + method = method.toUpperCase(); + this.logger.debug(`Registering handler '${handler.name}' for endpoint - ${method} ${endpointURI}`); + this._routes[endpointURI][method] = [handler, uniqueKey]; + }); + + // register endpoint for all HTTP methods + this._router.all(endpointURI); + + return wrapDestroy.call(this, handler, methods.slice(), endpointURI, uniqueKey); + } + + /** + * Remove all registered routes and handlers + * + * @public + */ + async removeAll() { + this.logger.debug('Removing all registered request handlers'); + + // make a copy to preserve order + await Promise.all(this._destroyHandlers.slice().map((destroy) => destroy())); + + assert.empty(this._destroyHandlers, 'destroyHandlers'); + assert.empty(this._routes, 'routes'); + + this._destroyHandlers = []; + this._router = new TinyRouter(); + this._routes = {}; + } +} + +/** + * @this {Router} + * + * @param {RequestHandler} handler + * @param {string[]} methods + * @param {string} endpointURI + * @param {Symbol} uniqueKey + * + * @returns {function(): Promise} callback to deregister the handler + */ +function wrapDestroy(handler, methods, endpointURI, uniqueKey) { + // hacky way to identify the handler's route and use it to deregister it + // NOTE: heavely relies on tiny-request-router implementation + const myRoute = this._router.routes[this._router.routes.length - 1]; + + const destroy = async () => { + if (handler === null) { + // destroyed already + return; + } + + const tmpHandler = handler; + handler = null; + + this.logger.debug(`Deregistering handler '${tmpHandler.name}' for '${endpointURI}' (${methods.join(', ')})`); + + // remove from handlers list + let idx = this._destroyHandlers.indexOf(destroy); + assert.safeNumberGrEq(idx, 0, 'idx'); + this._destroyHandlers.splice(idx, 1); + + // remove TRR route + idx = this._router.routes.indexOf(myRoute); + assert.safeNumberGrEq(idx, 0, 'idx'); + this._router.routes.splice(idx, 1); + + const registeredURI = this._routes[endpointURI]; + if (registeredURI) { + methods.forEach((method) => { + const methodHandler = registeredURI[method]; + if (methodHandler && methodHandler[0] === tmpHandler && methodHandler[1] === uniqueKey) { + delete registeredURI[method]; + } + }); + + if (Object.keys(registeredURI).length === 0) { + delete this._routes[endpointURI]; + } + } + + if (typeof tmpHandler.destroy !== 'function') { + return; + } + + await Promise.all(methods.map(async (method) => { + this.logger.debug(`Deregistering handler '${tmpHandler.name}' for '${method} ${endpointURI}'`); + try { + await tmpHandler.destroy(method, endpointURI); + } catch (error) { + this.logger.exception(`Uncaught error on attempt to deregistering handler '${tmpHandler.name}' for '${method} ${endpointURI}'`, error); + } + })); + }; + + this._destroyHandlers.push(destroy); + return destroy; +} + +module.exports = Router; diff --git a/src/lib/runtimeConfig/index.js b/src/lib/runtimeConfig/index.js index af115a08..830272f9 100644 --- a/src/lib/runtimeConfig/index.js +++ b/src/lib/runtimeConfig/index.js @@ -19,50 +19,37 @@ /* eslint-disable no-unused-expressions, no-nested-ternary, prefer-template */ /* eslint-disable no-use-before-define */ -const fs = require('fs'); - const configUtil = require('../utils/config'); const constants = require('../constants'); -const logger = require('../logger').getChild('runtimeConfig'); const miscUtil = require('../utils/misc'); const Service = require('../utils/service'); const Task = require('./task'); const updater = require('./updater'); -/** @module runtimeConfig */ - /** - * Runtime Config Class + * @module runtimeConfig * - * @property {logger.Logger} logger + * @typedef {import('./updater').AppContext} AppContext + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('./updater').ScriptConfig} ScriptConfig */ -class RuntimeConfig extends Service { - /** @param {updater.FSLikeObject} [fsUtil] */ - constructor(fsUtil) { - super(); - /** define static read-only props that should not be overriden */ - Object.defineProperties(this, { - logger: { - value: logger.getChild(this.constructor.name) - } - }); - this.fsUtil = fsUtil || fs; - this.restartsEnabled = true; - } +const EE_NAMESPACE = 'runtimecfg'; +/** + * Runtime Config Class + */ +class RuntimeConfigService extends Service { /** * Configure and start the service * * @param {function} onFatalError - function to call on fatal error to restart the service */ - _onStart() { - return new Promise((resolve) => { - this._taskLoop = Promise.resolve(); - this._currentTask = null; - this._nextTask = null; - resolve(); - }); + async _onStart() { + this._taskLoop = Promise.resolve(); + this._currentTask = null; + this._nextTask = null; } /** @@ -70,21 +57,16 @@ class RuntimeConfig extends Service { * * @param {boolean} [restart] - true if service going to be restarted */ - _onStop() { - return new Promise((resolve, reject) => { - this._taskLoop = null; - this._nextTask = null; + async _onStop() { + this._taskLoop = null; + this._nextTask = null; - Promise.resolve() - .then(() => this._currentTask && this._currentTask.isRunning() && this._currentTask.stop()) - .then( - () => { - this._currentTask = null; - }, - () => {} // ignore everything - ) - .then(resolve, reject); - }); + try { + await (this._currentTask && this._currentTask.isRunning() && this._currentTask.stop()); + this._currentTask = null; + } catch (error) { + // ignore everything + } } /** @returns {Promise} resolved with true when service destroyed or if it was destroyed already */ @@ -93,74 +75,93 @@ class RuntimeConfig extends Service { && this._offConfigUpdates.off() && (this._offConfigUpdates = null); - return super.destroy(); + return super.destroy() + .then((ret) => { + this._offMyEvents + && this._offMyEvents.off() + && (this._offMyEvents = null); + + return ret; + }); } - /** @param {restWorker.ApplicationContext} appCtx - application context */ - initialize(appCtx) { - if (appCtx.configMgr) { - this._offConfigUpdates = appCtx.configMgr.on('change', onConfigEvent.bind(this), { objectify: true }); - this.logger.debug('Subscribed to configuration updates.'); - } else { - this.logger.warning('Unable to subscribe to configuration updates!'); - } + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + this._offConfigUpdates = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'config.applied': 'config.applied' } + ]); } } /** - * @this ResourceMonitor + * @this RuntimeConfigService * * @param {Configuration} config - * - * @returns {Promise} resolved once config applied to the instance */ -function onConfigEvent(config) { - return Promise.resolve() - .then(() => { - this.logger.verbose('Config "change" event'); - this.logger.info(`Current runtime state: ${JSON.stringify(runtimeState())}`); +async function onConfigEvent(config) { + const applyConfig = () => { + /** + * Configuration is validated, no additional check required + */ + this.logger.debug('Config "change" event'); + this.logger.info(`Current runtime state: ${JSON.stringify(runtimeState())}`); - // even empty configuration should be processed - e.g. restore defaults - const runtimeConfig = configUtil.getTelemetryControls(config).runtime || {}; - const newRuntimeConfig = updater.enrichScriptConfig({}); // initialize with defaults + // even empty configuration should be processed - e.g. restore defaults + const runtimeConfig = configUtil.getTelemetryControls(config).runtime || {}; + const newRuntimeConfig = updater.enrichScriptConfig({}); // initialize with defaults - if (runtimeConfig.enableGC === true) { - this.logger.info('Going to try to enable GC (request from user).'); - newRuntimeConfig.gcEnabled = true; - } // disabled by default + if (runtimeConfig.enableGC === true) { + this.logger.info('Going to try to enable GC (request from user).'); + newRuntimeConfig.gcEnabled = true; + } // disabled by default - if (Number.isSafeInteger(runtimeConfig.maxHeapSize)) { - if (runtimeConfig.maxHeapSize <= constants.APP_THRESHOLDS.MEMORY.DEFAULT_HEAP_SIZE) { - // - need to remove CLI option from the script if presented - use default value then - // - can't go lower than default without affecting other apps - this.logger.info('Going to try to restore the default heap size (request from user).'); - } else { - // need to add/update CLI option to the script - this.logger.info(`Going to try to set the heap size to ${runtimeConfig.maxHeapSize} MB (request from user).`); - newRuntimeConfig.heapSize = runtimeConfig.maxHeapSize; - } - } // else use default value + if (Number.isSafeInteger(runtimeConfig.maxHeapSize)) { + if (runtimeConfig.maxHeapSize <= constants.APP_THRESHOLDS.MEMORY.DEFAULT_HEAP_SIZE) { + // - need to remove CLI option from the script if presented - use default value then + // - can't go lower than default without affecting other apps + this.logger.info('Going to try to restore the default heap size (request from user).'); + } else { + // need to add/update CLI option to the script + this.logger.info(`Going to try to set the heap size to ${runtimeConfig.maxHeapSize} MB (request from user).`); + newRuntimeConfig.heapSize = runtimeConfig.maxHeapSize; + } + } // else use default value - this.logger.info(`New runtime configuration: ${JSON.stringify(newRuntimeConfig)}`); - this.logger.debug('Scheduling an update to apply the new runtime configuration.'); + if (Number.isSafeInteger(runtimeConfig.httpTimeout)) { + this.logger.info(`Going to try to set the HTTP timeout value to ${runtimeConfig.httpTimeout} seconds (request from user).`); + newRuntimeConfig.httpTimeout = runtimeConfig.httpTimeout * 1000; // to ms. + } - addTask.call(this, newRuntimeConfig); - }).catch((err) => { - this.logger.exception('Error caught on attempt to apply configuration to Runtime Config:', err); - }); + this.logger.info(`New runtime configuration: ${JSON.stringify(newRuntimeConfig)}`); + this.logger.debug('Scheduling an update to apply the new runtime configuration.'); + + addTask.call(this, newRuntimeConfig); + }; + + try { + applyConfig(); + } catch (error) { + this.logger.exception('Error caught on attempt to apply configuration to Runtime Config:', error); + } finally { + // emit in any case to show we are done with config processing + this.ee.safeEmitAsync('config.applied'); + } } /** * @private * - * @this RuntimeConfig + * @this RuntimeConfigService * - * @returns {updater.AppContext} + * @returns {AppContext} */ function makeAppCtx() { const log = this.logger; return { - fsUtil: this.fsUtil, + fsUtil: miscUtil.fs, logger: { debug(msg) { log.debug(msg); }, error(msg) { log.error(msg); }, @@ -174,7 +175,7 @@ function makeAppCtx() { /** * @private * - * @returns {object} current state of the runtime + * @returns {NodeRuntimeState} current state of the runtime */ function runtimeState() { return { @@ -188,9 +189,9 @@ function runtimeState() { * * @private * - * @this RuntimeConfig + * @this RuntimeConfigService * - * @param {updater.ScriptConfig} config - configuration to apply + * @param {ScriptConfig} config - configuration to apply */ function addTask(config) { taskLoop.call(this, new Task(config, makeAppCtx.call(this), this.logger.getChild('task'))); @@ -201,7 +202,7 @@ function addTask(config) { * * @private * - * @this RuntimeConfig + * @this RuntimeConfigService * * @param {Task} task - task to add to the loop */ @@ -253,4 +254,11 @@ function taskLoop(task) { return Promise.resolve(); } -module.exports = RuntimeConfig; +module.exports = RuntimeConfigService; + +/** + * @typedef NodeRuntimeState + * @type {object} + * @property {boolean} gcEnabled - true if GC enabled + * @property {integer} maxHeapSize - max V8 heap size in MB + */ diff --git a/src/lib/runtimeConfig/task.js b/src/lib/runtimeConfig/task.js index aed767c5..2525503e 100644 --- a/src/lib/runtimeConfig/task.js +++ b/src/lib/runtimeConfig/task.js @@ -16,20 +16,29 @@ 'use strict'; -const assert = require('assert'); const getKey = require('lodash/get'); +const isEqual = require('lodash/isEqual'); const machina = require('machina'); const pathUtil = require('path'); const uuid = require('uuid').v4; const constants = require('../constants'); +const dacli = require('../utils/dacli'); const deviceUtil = require('../utils/device'); const logger = require('../logger').getChild('runtimeConfig').getChild('Task'); const miscUtil = require('../utils/misc'); const SafeEventEmitter = require('../utils/eventEmitter'); const updater = require('./updater'); -/** @module runtimeConfig/task */ +/** + * @private + * + * @module runtimeConfig/task + * + * @typedef {import('./updater').AppContext} AppContext + * @typedef {import('../logger').Logger} Logger + * @typedef {import('./updater').ScriptConfig} ScriptConfig + */ const DACLI_SCRIPT_NAME = 'telemetry_delete_me__async_restnoded_updater'; const UPDATER_SCRIPT = pathUtil.join(__dirname, 'updater.js'); @@ -50,34 +59,20 @@ const UPDATER_SCRIPT = pathUtil.join(__dirname, 'updater.js'); * restart-service-force ----> done || stopped */ -/** - * @private - * - * @returns {Promise} resolved with true if `bash` enabled or false otherwise - */ -function isShellEnabled() { - return Promise.resolve() - .then(() => deviceUtil.makeDeviceRequest( - constants.LOCAL_HOST, - '/mgmt/tm/sys/db/systemauth.disablebash' - )) - .then((retval) => retval.value, () => false); -} - /** * @private * * @param {string} cmd - command to execute * - * @returns {Promise} true if command succeed or false otherwise + * @returns {boolean} true if command succeed or false otherwise */ -function runRemoteCmd(cmd) { - return Promise.resolve() - .then(() => ( - new deviceUtil.DeviceAsyncCLI({ - scriptName: DACLI_SCRIPT_NAME - })).execute(cmd)) - .then(() => true, () => false); +async function runRemoteCmd(cmd) { + try { + await dacli(cmd, { scriptName: DACLI_SCRIPT_NAME }); + } catch (err) { + return false; + } + return true; } const taskFsm = new machina.BehavioralFsm({ @@ -101,11 +96,7 @@ const taskFsm = new machina.BehavioralFsm({ if (scriptConfig === null) { task.logger.error('Unable to read configuration from the startup script.'); } else { - try { - assert.deepStrictEqual(config, scriptConfig); - } catch (error) { - configApplied = false; - } + configApplied = isEqual(config, scriptConfig); } if (!configApplied) { task.logger.error('Configuration was not applied to the script!'); @@ -126,11 +117,7 @@ const taskFsm = new machina.BehavioralFsm({ delete scriptConfig.id; delete config.id; - try { - assert.deepStrictEqual(config, scriptConfig); - } catch (error) { - hasChanges = true; - } + hasChanges = !isEqual(config, scriptConfig); if (!hasChanges) { task.logger.debug('No changes found between running configuration and the new one.'); @@ -165,38 +152,41 @@ const taskFsm = new machina.BehavioralFsm({ } }, 'restart-service': { - _onEnter(task) { + async _onEnter(task) { task.logger.warning('Restarting service to apply new changes for the runtime configuraiton!'); - runRemoteCmd('bigstart restart restnoded') - .then((success) => { - if (success) { - task.logger.warning('Service will be restarted in a moment to apply changes in the configuration!'); - task._restartRequested = true; - } else { - task.logger.error('Unable to restart service via bigstart. Calling process.exit(0) instead to restart it'); - } - this._doTransition(task, success ? 'done' : 'restart-service-force'); - }); + const success = await runRemoteCmd('bigstart restart restnoded'); + + if (success) { + task.logger.warning('Service will be restarted in a moment to apply changes in the configuration!'); + task._restartRequested = true; + } else { + task.logger.error('Unable to restart service via bigstart. Calling process.exit(0) instead to restart it'); + } + this._doTransition(task, success ? 'done' : 'restart-service-force'); } }, 'restart-service-delay': { - _onEnter(task) { + async _onEnter(task) { task.logger.warning('New configuration was successfully applied to the startup script! Scheduling service restart in 1 min.'); - miscUtil.sleep(60 * 1000) - .then(() => this._doTransition(task, 'restart-service')); + await miscUtil.sleep(60 * 1000); + this._doTransition(task, 'restart-service'); } }, 'shell-check': { - _onEnter(task) { - isShellEnabled() - .then((enabled) => { - if (enabled) { - task.logger.debug('Shell available, proceeding with task execution.'); - } else { - task.logger.debug('Shell not available, unable to proceed with task execution.'); - } - this._doTransition(task, enabled ? 'updater-run' : 'done'); - }); + async _onEnter(task) { + let enabled = false; + try { + enabled = await deviceUtil.isShellEnabled(constants.LOCAL_HOST); + } catch (err) { + task.logger.exception('Error on attempt to check shell status:', err); + } + + if (enabled) { + task.logger.debug('Shell available, proceeding with task execution.'); + } else { + task.logger.debug('Shell not available, unable to proceed with task execution.'); + } + this._doTransition(task, enabled ? 'updater-run' : 'done'); } }, stopped: { @@ -211,21 +201,20 @@ const taskFsm = new machina.BehavioralFsm({ } }, 'updater-run': { - _onEnter(task) { + async _onEnter(task) { task.logger.debug('Trying to execute "updater" script'); - runRemoteCmd(`${process.argv[0]} ${UPDATER_SCRIPT}`) - .then((success) => { - let logs = updater.readLogsFile(task.appCtx); - if (logs === null) { - logs = 'no logs available!'; - } - task.logger.debug(`Device Async CLI logs:\n${logs}`); - - if (!success) { - task.logger.error('Attempt to update the runtime configuration failed! See logs for more details.'); - } - this._doTransition(task, 'config-post-check'); - }); + const success = await runRemoteCmd(`${process.argv[0]} ${UPDATER_SCRIPT}`); + + let logs = updater.readLogsFile(task.appCtx); + if (logs === null) { + logs = 'no logs available!'; + } + task.logger.debug(`Device Async CLI logs:\n${logs}`); + + if (!success) { + task.logger.error('Attempt to update the runtime configuration failed! See logs for more details.'); + } + this._doTransition(task, 'config-post-check'); } } }, @@ -379,17 +368,16 @@ taskFsm.on('transition', (data) => data.client.ee.emitAsync('transition', data)) * - transition(data) - state transition * * @property {SafeEventEmitter} ee - event emitter - * @property {logger.Logger} logger - logger - * @property {boolean} restartsEnabled - true if restarts on fatal error at `running` state are enabled + * @property {Logger} logger - logger */ class Task { /** - * @param {updater.ScriptConfig} config - * @param {updater.AppContext} appCtx - * @param {logger.Logger} [logger] - logger instance + * @param {ScriptConfig} config + * @param {AppContext} appCtx + * @param {Logger} [logger] - logger instance */ constructor(config, appCtx, _logger) { - // create read-only properties + /** define static read-only props that should not be overriden */ Object.defineProperties(this, { ee: { value: new SafeEventEmitter() @@ -402,6 +390,7 @@ class Task { this.logger = _logger || logger; this.runtimeConfig = config; + this.ee.logger = this.logger.getChild('ee'); this.ee.on('transition', (data) => this.logger.debug(`transition from "${data.fromState}" to "${data.toState}" (action=${data.action})`)); } diff --git a/src/lib/runtimeConfig/updater.js b/src/lib/runtimeConfig/updater.js index 238bd431..db0c06a4 100644 --- a/src/lib/runtimeConfig/updater.js +++ b/src/lib/runtimeConfig/updater.js @@ -16,12 +16,19 @@ 'use strict'; +/* eslint-disable no-cond-assign */ + const assert = require('assert'); const Console = require('console').Console; +const escapeRegExp = require('lodash/escapeRegExp'); const fs = require('fs'); const pathUtil = require('path'); -/** @module runtimeConfig/updater */ +/** + * @private + * + * @module runtimeConfig/updater + */ /** * THE SCRIPT MAY BE RUN IN TWO CONTEXTS: @@ -29,13 +36,16 @@ const pathUtil = require('path'); * - as regular node.js script - none of iApp LX libs are available */ +const HTTP_TIMEOUT_DEFAULT = 60000; // in ms. + // approximation for default heap size const NODEJS_DEFAULT_HEAP_SIZE = 1400; // should be used to check single-line only const RESTNODE_EXEC_LINE_REGEX = /^.*exec\s*\/usr\/bin\/f5-rest-node/; +const RESTNODE_SCRIPT_REGEX = /^.*restnode\.js/; -const SCRIPT_CONFIG_ID = /# ID:[a-zA-Z0-9-]+/gm; +const SCRIPT_CONFIG_ID = /# ID:[a-zA-Z0-9-]+/g; // the restnode startup script const RESTNODE_SCRIPT_FPATH = '/etc/bigstart/scripts/restnoded'; @@ -46,6 +56,11 @@ const SCRIPT_CONFIG_FPATH = pathUtil.join(__dirname, 'config.json'); // most recent logs const SCRIPT_LOGS_FPATH = pathUtil.join(__dirname, 'logs.txt'); +const EXPOSE_GC_OPTION = 'expose-gc'; +const HEAP_OPTION_NEW_STYLE = 'max-old-space-size'; +const HEAP_OPTION_OLD_STYLE = 'max_old_space_size'; +const HTTP_TIMEOUT_OPTION = 'k'; + /** * Add notes about restoring original behavior to the script's content * @@ -75,7 +90,7 @@ function addAttentionBlockIfNeeded(script, execLine, appCtx) { + `${indent}# The block below should be removed to restore original behavior!\n` + execLine; - return script.replace(execLine, comment); + return script.replace(new RegExp(`^${escapeRegExp(execLine)}$`, 'm'), comment); } /** @@ -119,12 +134,12 @@ function applyScriptConfig(script, config, appCtx) { // enable/disable GC if (currentConfig.gcEnabled !== config.gcEnabled) { appCtx.logger.info('Updating GC config.'); - newExecLine = newExecLine.replace(/ --expose-gc/g, ''); + newExecLine = newExecLine.replace(new RegExp(` --${EXPOSE_GC_OPTION}`, 'g'), ''); if (config.gcEnabled) { appCtx.logger.info('Enabling GC config.'); const substr = newExecLine.match(RESTNODE_EXEC_LINE_REGEX)[0]; newExecLine = newExecLine.slice(0, substr.length) - .concat(' --expose-gc') + .concat(` --${EXPOSE_GC_OPTION}`) .concat(newExecLine.slice(substr.length)); } else { appCtx.logger.info('Disabling GC config.'); @@ -134,7 +149,7 @@ function applyScriptConfig(script, config, appCtx) { // enable/disable custom heap size if (currentConfig.heapSize !== config.heapSize) { appCtx.logger.info('Upading heap size.'); - newExecLine = newExecLine.replace(/ --max_old_space_size=\d+/g, ''); + newExecLine = newExecLine.replace(new RegExp(` --(?:${HEAP_OPTION_NEW_STYLE}|${HEAP_OPTION_OLD_STYLE})=\\d+`, 'g'), ''); if (config.heapSize && Number.isSafeInteger(config.heapSize) && config.heapSize > NODEJS_DEFAULT_HEAP_SIZE @@ -142,20 +157,36 @@ function applyScriptConfig(script, config, appCtx) { appCtx.logger.info(`Setting heap size to ${config.heapSize} MB.`); const substr = newExecLine.match(RESTNODE_EXEC_LINE_REGEX)[0]; newExecLine = newExecLine.slice(0, substr.length) - .concat(` --max_old_space_size=${config.heapSize}`) + .concat(` --${HEAP_OPTION_NEW_STYLE}=${config.heapSize}`) .concat(newExecLine.slice(substr.length)); } else { appCtx.logger.info(`Setting heap size to default value - ${NODEJS_DEFAULT_HEAP_SIZE} MB.`); } } + // http timeout + if (currentConfig.httpTimeout !== config.httpTimeout) { + appCtx.logger.info('Upading HTTP timeout.'); + newExecLine = newExecLine.replace(new RegExp(` -${HTTP_TIMEOUT_OPTION} \\d+`, 'g'), ''); + + if (config.httpTimeout <= HTTP_TIMEOUT_DEFAULT) { + appCtx.logger.info(`Setting HTTP timeout to default value - ${HTTP_TIMEOUT_DEFAULT} ms.`); + } else { + appCtx.logger.info(`Setting HTT timeout value to ${config.httpTimeout} ms.`); + const substr = newExecLine.match(RESTNODE_SCRIPT_REGEX)[0]; + newExecLine = newExecLine.slice(0, substr.length) + .concat(` -${HTTP_TIMEOUT_OPTION} ${config.httpTimeout}`) + .concat(newExecLine.slice(substr.length)); + } + } + if (currentConfig.id) { script = script.replace(new RegExp(`^.*ID:${currentConfig.id}.*(?:\r\n|\n)`, 'm'), ''); } newExecLine = `${getIndent(originExecLine)}# ID:${config.id}\n${newExecLine}`; script = addAttentionBlockIfNeeded(script, originExecLine, appCtx); - script = script.replace(originExecLine, newExecLine); + script = script.replace(new RegExp(`^${escapeRegExp(originExecLine)}$`, 'm'), newExecLine); return script; } @@ -196,6 +227,9 @@ function enrichScriptConfig(config) { if (typeof config.heapSize === 'undefined') { config.heapSize = NODEJS_DEFAULT_HEAP_SIZE; } + if (typeof config.httpTimeout === 'undefined') { + config.httpTimeout = HTTP_TIMEOUT_DEFAULT; + } return config; } @@ -220,12 +254,24 @@ function fetchConfigFromScript(script, appCtx) { const config = enrichScriptConfig({}); // check for GC - config.gcEnabled = execLine.indexOf('--expose-gc') !== -1; + config.gcEnabled = execLine.indexOf(`--${EXPOSE_GC_OPTION}`) !== -1; + + // - check for custom heap size + // - check both options due back-compatibility + // - take the last match only + const heapRegex = new RegExp(`--(?:${HEAP_OPTION_NEW_STYLE}|${HEAP_OPTION_OLD_STYLE})=(\\d+)`, 'g'); + const matches = []; + let match; + while ((match = heapRegex.exec(execLine)) !== null) { + matches.push(match); + } + if (matches.length) { + config.heapSize = parseInt(matches[matches.length - 1][1], 10); + } - // check for custom heap size - const heapMatch = execLine.match(/--max_old_space_size=(\d+)/); - if (heapMatch) { - config.heapSize = parseInt(heapMatch[1], 10); + const httpTimeoutRegex = new RegExp(`-${HTTP_TIMEOUT_OPTION} (\\d+)`, 'g'); + if ((match = httpTimeoutRegex.exec(execLine))) { + config.httpTimeout = parseInt(match[1], 10); } const scriptIDMatch = script.match(SCRIPT_CONFIG_ID); @@ -429,4 +475,5 @@ module.exports = { * @property {boolean} gcEnabled - true when GC enabled * @property {number} heapSize - heap size (in MB) * @property {string} id - configuration ID + * @property {number} httpTimeout - HTTP timeout (in ms.) */ diff --git a/src/lib/storage/index.js b/src/lib/storage/index.js new file mode 100644 index 00000000..9f5b8066 --- /dev/null +++ b/src/lib/storage/index.js @@ -0,0 +1,121 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const Service = require('../utils/service'); +const Storage = require('./storage'); + +/** + * @module storage + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../storage').Key} Key + * @typedef {import('../../nodejs/restWorker').RestWorker} RestWorker + */ + +const EE_NAMESPACE = 'storage'; + +/** + * Storage Class + */ +class StorageService extends Service { + /** @inheritdoc */ + async _onStart() { + await this._createStorage(); + this._registerEvents(); + } + + /** @inheritdoc */ + async _onStop() { + if (this._listeners) { + this._listeners.forEach((listener) => listener.off()); + } + + this._storage = null; + } + + /** + * @param {ApplicationEvents} appEvents - application events + * @param {RestWorker} restWorker - RestWorker instance + */ + initialize(appEvents, restWorker) { + // function to register subscribers + this._registerEvents = () => { + this._listeners = [ + appEvents.on(`*.${EE_NAMESPACE}.get`, getData.bind(this), { objectify: true }), + appEvents.on(`*.${EE_NAMESPACE}.remove`, removeData.bind(this), { objectify: true }), + appEvents.on(`*.${EE_NAMESPACE}.set`, setData.bind(this), { objectify: true }) + ]; + this.logger.debug(`Subscribed to *.${EE_NAMESPACE}.* requests.`); + }; + this._createStorage = async () => { + this._storage = new Storage(restWorker, this.logger); + await this._storage.load(); + }; + } +} + +/** + * @param {Key} key + * @param {function(error: Error | null, data: any)} callback + */ +async function getData(key, callback) { + try { + callback(null, await this._storage.get(key)); + } catch (error) { + callback(error); + } +} + +/** + * @param {Key} key + * @param {any} value + * @param {function(error: Error | null)} [callback] + */ +async function setData(key, value, callback) { + let error = null; + try { + await this._storage.set(key, value); + } catch (saveError) { + error = saveError; + } + + if (callback) { + callback(error); + } +} + +/** + * @param {Key} key + * @param {function(error: Error | null)} [callback] + */ +async function removeData(key, callback) { + let error = null; + try { + await this._storage.remove(key); + } catch (removeError) { + error = removeError; + } + + if (callback) { + callback(error); + } +} + +module.exports = StorageService; diff --git a/src/lib/storage/storage.js b/src/lib/storage/storage.js new file mode 100644 index 00000000..36367856 --- /dev/null +++ b/src/lib/storage/storage.js @@ -0,0 +1,372 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const getData = require('lodash/get'); +const setData = require('lodash/set'); +const unsetData = require('lodash/unset'); + +const assert = require('../utils/assert'); +const logger = require('../logger'); +const util = require('../utils/misc'); + +/** + * @module persistentStorage/storage + * + * @typedef {import('../logger').Logger} Logger + * @typedef {import('../../nodejs/restWorker').RestWorker} RestWorker + */ + +/** + * Rest Storage Interface. + * 'get' - not async, reading all data from cache and calls callback immediately. + * 'set' - semi-async, saves data to cache immediately, but callback will be called + * once data is really saved to Rest Storage. Cache can be not in-sync + * with the data in Rest Storage. + * 'remove' - semi-async, removes data from cache immediately, but callback will + * be called once updated data is really saved to Rest Storage. Cache can be + * not in-sync with the actual data in Rest Storage. + */ +class RestStorage { + /** + * @param {RestWorker} restWorker - RestWorker instance + * @param {Logger} parentLogger - parent logger + */ + constructor(restWorker, parentLogger) { + assert.exist(restWorker, 'restWorker'); + assert.instanceOf(parentLogger, logger.constructor, 'logger'); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + logger: { + value: parentLogger.getChild('RestStorage') + }, + restWorker: { + value: restWorker + } + }); + + this._cache = getBaseState(); + this._savePromise = null; + this._inSaveProcess = false; + this._loadPromise = null; + } + + /** + * @param {Key} key + * + * @returns {any} data + */ + async get(key) { + assert.storage.key(key); + return getData(this._cache._data_, key); + } + + /** + * @param {Key} key + * @param {any} data + * + * @returns {Promise} resolved once data saved + */ + set(key, data) { + assert.storage.key(key); + setData(this._cache._data_, key, data); + return save.call(this); + } + + /** + * @param {Key} key + * + * @returns {void} once key removed + */ + async remove(key) { + assert.storage.key(key); + unsetData(this._cache._data_, key); + await save.call(this); + } + + /** @returns {void} once data saved */ + async save() { + await save.call(this); + } + + /** @returns {void} once data loaded */ + async load() { + await load.call(this); + } +} + +/** + * Load all data from Rest Storage. + * Can be blocked for a while by save operation. + * + * @this {RestStorage} + * + * @returns {void} once data loaded from Rest Storage + */ +async function load() { + let loadPromise = this._loadPromise; + + if (!loadPromise) { + loadPromise = this._savePromise || Promise.resolve(); + loadPromise = loadPromise.then(() => unsafeLoad.call(this)) + .then((state) => { + this._loadPromise = null; + this._cache = validateLoadedState(state || getBaseState()); + this.logger.debug('Application state loaded from the storage'); + }) + .catch((err) => { + loadPromise.loadError = err; + this._loadPromise = null; + this.logger.exception('Unable to load application state from the storage:', err); + }); + + this._loadPromise = loadPromise; + } + + return new Promise((resolve, reject) => { + loadPromise.then(() => { + if (loadPromise.loadError) { + reject(loadPromise.loadError); + } else { + resolve(); + } + }); + }); +} + +/** + * Save current data to Rest Storage. + * Can be blocked for a while by load operation. + * + * @this {RestStorage} + * + * @returns {void} once data saved to Rest Storage + */ +async function save() { + assert.exist(this._cache, '_cache'); + + let savePromise = this._savePromise; + if (!savePromise) { + let copiedData; + + savePromise = this._loadPromise || Promise.resolve(); + savePromise = savePromise.then(() => { + this._inSaveProcess = true; + try { + copiedData = prepareToSave(this._cache); + } catch (err) { + return Promise.reject(err); + } + return unsafeSave.call(this, copiedData); + }) + .then(() => { + /** + * Once data passed to restWorker.saveState it might be modified by restWorker. + * We need to copy those 'service' properties back to '_cache' to be able to + * save data again later. We can't assign data directly like 'this._cache = copiedData' + * because '_cache' might be updated by the user already - e.g. new data set. + */ + Object.keys(copiedData).forEach((key) => { + if (key !== '_data_') { + this._cache[key] = copiedData[key]; + } + }); + this.logger.debug('Application state saved to the storage'); + }) + .catch((err) => { + savePromise.saveError = err; + this.logger.exception('Unable to save application state to the storage:', err); + }) + .then(() => { + this._savePromise = null; + this._inSaveProcess = false; + }); + + this._savePromise = savePromise; + } else if (this._inSaveProcess) { + // try again to save later, because we are too late at that time + savePromise = savePromise.then(() => save.call(this)); + } + + return new Promise((resolve, reject) => { + savePromise.then(() => { + if (savePromise.saveError) { + reject(savePromise.saveError); + } else { + resolve(); + } + }); + }); +} + +/** + * Save data (override existing one) to Rest Storage without any checks + * + * @this {RestStorage} + * + * @param {object} data - data to save + * + * @returns {void} once data saved to Rest Storage + */ +async function unsafeSave(data) { + await new Promise((resolve, reject) => { + this.restWorker.saveState(null, data, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +/** + * Load data from Rest Storage without any checks + * + * @this {RestStorage} + * + * @returns {object} data loaded from Rest Storage + */ +async function unsafeLoad() { + return new Promise((resolve, reject) => { + this.restWorker.loadState(null, (err, state) => { + if (err) { + reject(err); + } else { + resolve(state); + } + }); + }); +} + +/** @returns {object} base state object */ +function getBaseState() { + return { + _data_: {} + }; +} + +/** + * Validate loaded data and convert it to appropriate format + * + * @param {object} state - object to verify + * + * @returns {object} verified object + */ +function validateLoadedState(state) { + // looks like it is old versions + if (typeof state._data_ !== 'undefined') { + if (typeof state._data_ === 'string') { + state._data_ = JSON.parse(state._data_); + } + if (state._data_ === null) { + // otherwise lodash.set will ignore all operations + state._data_ = {}; + } + } else { + state._data_ = {}; + if (typeof state.config !== 'undefined') { + state._data_.config = state.config; + delete state.config; + } + } + return state; +} + +/** + * Prepare data to be saved + * + * @param {object} state - current state + * + * @returns {object} object ready to be saved + */ +function prepareToSave(state) { + const newState = Object.assign({}, state); + newState._data_ = JSON.stringify(state._data_); + + return newState; +} + +/** + * Persistent Storage Proxy + * + * @class + * + * @property {Storage} storage - storage instance + */ +class PersistentStorage { + /** + * Constructor + * + * @see {RestWorker} args + */ + constructor(...args) { + this.storage = new RestStorage(...args); + } + + /** + * @param {Key} key + * + * @returns {any} data + */ + async get(key) { + let value = await this.storage.get(key); + if (typeof value === 'object') { + value = util.deepCopy(value); + } + return value; + } + + /** + * @param {Key} key + * @param {any} data + * + * @returns {Promise} resolved once data saved + */ + set(key, data) { + if (typeof data === 'object') { + data = util.deepCopy(data); + } + return this.storage.set(key, data); + } + + /** + * @param {Key} key + * + * @returns {void} once key removed + */ + async remove(key) { + await this.storage.remove(key); + } + + /** @returns {void} once data loaded */ + async load() { + await this.storage.load(); + } + + /** @returns {void} once data saved */ + async save() { + await this.storage.save(); + } +} + +module.exports = PersistentStorage; + +/** + * @typedef {string | string[]} Key + */ diff --git a/src/lib/systemPoller.js b/src/lib/systemPoller.js deleted file mode 100644 index adde3989..00000000 --- a/src/lib/systemPoller.js +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const configUtil = require('./utils/config'); -const configWorker = require('./config'); -const constants = require('./constants'); -const dataPipeline = require('./dataPipeline'); -const errors = require('./errors'); -const logger = require('./logger'); -const promiseUtil = require('./utils/promise'); -const SystemStats = require('./systemStats'); -const timers = require('./utils/timers'); -const tracerMgr = require('./tracerManager'); -const util = require('./utils/misc'); - -/** @module systemPoller */ - -// key - poller name, value - { timer, config } -const POLLER_TIMERS = {}; - -class NoPollersError extends errors.ConfigLookupError {} - -/** - * Returns tracked system pollers - * - * @returns {Object} - Object containing systemPollers ({ pollerTraceName: { timer, config } }) - */ -function getPollerTimers() { - return POLLER_TIMERS; -} - -function findSystemOrPollerConfigs(originalConfig, sysOrPollerName, pollerName, namespace) { - // If namespace is undefined, assumption is we're querying for objects in the 'default namespace' - const namespaceInfo = namespace ? ` in Namespace '${namespace}'` : ''; - namespace = namespace || constants.DEFAULT_UNNAMED_NAMESPACE; - const systemPollers = configUtil.getTelemetrySystemPollers(originalConfig, namespace); - let pollers = []; - - if (sysOrPollerName && pollerName) { - // probably system's and poller's names - pollers = systemPollers.filter((p) => p.name === pollerName && p.systemName === sysOrPollerName); - } else { - // each object has unique name per namespace - // so, one of the system or poller will be 'undefined' - pollers = systemPollers.filter((p) => p.systemName === sysOrPollerName); - } - - if (pollers.length === 0) { - if (pollerName) { - throw new errors.ObjectNotFoundInConfigError(`System Poller with name '${pollerName}' doesn't exist in System '${sysOrPollerName}'${namespaceInfo}`); - } - throw new errors.ObjectNotFoundInConfigError(`System or System Poller with name '${sysOrPollerName}' doesn't exist or has no configured System Pollers${namespaceInfo}`); - } - return pollers; -} - -function getEnabledPollerConfigs(originalConfig, includeDisabled) { - const pollers = configUtil.getTelemetrySystemPollers(originalConfig); - if (includeDisabled) { - return pollers; - } - return pollers.filter((p) => p.enable); -} - -function applyConfig(originalConfig) { - const currPollers = module.exports.getPollerTimers(); - const systemPollers = getEnabledPollerConfigs(originalConfig); - const newPollerIDs = []; - const promises = []; - - systemPollers.forEach((pollerConfig) => { - const key = pollerConfig.traceName; - const existingPoller = currPollers[key]; - newPollerIDs.push(key); - if (!pollerConfig.skipUpdate || !existingPoller) { - pollerConfig.tracer = tracerMgr.fromConfig(pollerConfig.trace); - const baseMsg = `system poller ${key}. Interval = ${pollerConfig.interval} sec.`; - // add to data context to track source poller config and destination(s) - pollerConfig.destinationIds = configUtil.getReceivers(originalConfig, pollerConfig).map((r) => r.id); - if (pollerConfig.interval === 0) { - logger.info(`Configuring non-polling ${baseMsg}`); - if (currPollers[key] && currPollers[key].timer) { - promises.push(currPollers[key].timer.stop() - .catch(((error) => logger.exception(`Unable to stop timer for System Poller "${key}"`, error)))); - } - currPollers[key] = undefined; - } else if (currPollers[key] && currPollers[key].timer) { - logger.info(`Updating ${baseMsg}`); - promises.push(currPollers[key].timer.update(safeProcess, pollerConfig.interval, pollerConfig) - .catch(((error) => logger.exception(`Unable to update timer for System Poller "${key}"`, error)))); - currPollers[key].config = pollerConfig; - } else { - logger.info(`Starting ${baseMsg}`); - currPollers[key] = { - timer: new timers.SlidingTimer(safeProcess, { - abortOnFailure: false, - intervalInS: pollerConfig.interval, - logger: logger.getChild(`${pollerConfig.traceName}.timer`) - }, pollerConfig), - config: pollerConfig - }; - promises.push(currPollers[key].timer.start() - .catch(((error) => logger.exception(`Unable to start timer for System Poller "${key}"`, error)))); - } - } - }); - - Object.keys(currPollers).forEach((key) => { - if (newPollerIDs.indexOf(key) === -1) { - logger.info(`Disabling/removing system poller ${key}`); - // for pollers with interval=0, the key exists, but value is undefined - if (!util.isObjectEmpty(currPollers[key]) && currPollers[key].timer) { - promises.push(currPollers[key].timer.stop() - .catch(((error) => logger.exception(`Unable to stop timer for System Poller "${key}"`, error)))); - } - delete currPollers[key]; - } - }); - - return promiseUtil.allSettled(promises); -} - -function enablePollers() { - const currentPollers = module.exports.getPollerTimers(); - const promises = []; - - Object.keys(currentPollers).forEach((pollerKey) => { - const poller = currentPollers[pollerKey]; - if (poller && poller.config && poller.config.interval > 0) { - logger.info(`Enabling system poller ${pollerKey}. Interval = ${poller.config.interval} sec.`); - if (poller.timer) { - // Update timer to enable/re-enable the poller - promises.push(poller.timer.update(safeProcess, poller.config.interval, poller.config) - .catch(((error) => logger.exception(`Unable to update timer for System Poller "${pollerKey}"`, error)))); - } else { - poller.timer = new timers.SlidingTimer(safeProcess, { - abortOnFailure: false, - intervalInS: poller.config.interval, - logger: logger.getChild(`${poller.config.traceName}.timer`) - }, poller.config); - promises.push(poller.timer.start() - .catch(((error) => logger.exception(`Unable to start timer for System Poller "${pollerKey}"`, error)))); - } - } - }); - return promiseUtil.allSettled(promises); -} - -function disablePollers() { - const currentPollers = module.exports.getPollerTimers(); - const promises = []; - - Object.keys(currentPollers).forEach((pollerKey) => { - const poller = currentPollers[pollerKey]; - if (poller && poller.config && poller.config.interval > 0) { - logger.info(`Disabling system poller ${pollerKey}`); - if (poller.timer) { - promises.push(poller.timer.stop() - .catch(((error) => logger.exception(`Unable to stop timer for System Poller "${pollerKey}"`, error)))); - } - } - }); - return promiseUtil.allSettled(promises); -} - -/** - * Process system(s) stats - * - * @param {Object} args - args object - * @param {Object} args.config - system config - * @param {Boolean} [args.process] - determine whether to process through pipeline - * @param {module:util~Tracer} [args.tracer] - tracer to write to disk - * @param [] - * - * @returns {Promise} Promise which is resolved with data sent - */ -function process() { - const config = arguments[0]; - const options = arguments.length > 1 ? arguments[1] : {}; - const tracer = config.tracer; - const startTimestamp = new Date().toISOString(); - logger.debug('System poller cycle started'); - config.name = config.traceName; - const systemStats = new SystemStats(config); - return systemStats.collect() - .then((normalizedData) => { - // inject service data - const telemetryServiceInfo = { - pollingInterval: config.interval, - cycleStart: startTimestamp, - cycleEnd: (new Date()).toISOString() - }; - normalizedData.telemetryServiceInfo = telemetryServiceInfo; - normalizedData.telemetryEventCategory = constants.EVENT_TYPES.SYSTEM_POLLER; - // end inject service data - - const dataCtx = { - data: normalizedData, - type: constants.EVENT_TYPES.SYSTEM_POLLER, - isCustom: systemStats.isCustom, - sourceId: config.id, - destinationIds: config.destinationIds - }; - - return dataPipeline.process(dataCtx, { - noConsumers: options.requestFromUser, - tracer, - actions: config.dataOpts.actions, - deviceContext: systemStats.contextData - }); - }) - .then((dataCtx) => { - logger.debug('System poller cycle finished'); - return dataCtx; - }) - .catch((e) => { - throw e; - }); -} - -/** - * Safe process - start process system(s) stats safely - * - * @async - * @see module:systemPoller~process - * - * @returns {Promise.} Promise resolved with data from System Poller - */ -function safeProcess() { - const requestFromUser = (arguments.length > 1 ? arguments[1] : {}).requestFromUser; - try { - // eslint-disable-next-line - return module.exports.process.apply(null, arguments) - .catch((err) => { - logger.exception('systemPoller:safeProcess unhandled exception in promise-chain', err); - if (requestFromUser) { - return Promise.reject(err); - } - return Promise.resolve(); - }); - } catch (err) { - logger.exception('systemPoller:safeProcess unhandled exception', err); - if (requestFromUser) { - return Promise.reject(new Error(`systemPoller:safeProcess unhandled exception: ${err}`)); - } - } - return Promise.resolve(); -} - -/** - * Get System Poller config if exists - * - * @param {String} sysOrPollerName - system name or poller name - * @param {Object} [options] - optional values - * @param {String} [options.pollerName] - poller name - * @param {String} [options.namespace] - namespace name - * @param {Boolean} [options.includeDisabled = false] - whether to include disabled pollers - * - * @returns {Promise} resolved with poller's config - */ -function getPollersConfig(sysOrPollerName, options) { - options = options || {}; - const includeDisabled = (typeof options.includeDisabled === 'undefined') ? false : options.includeDisabled; - return Promise.resolve() - .then(() => findSystemOrPollerConfigs( - configWorker.currentConfig, - sysOrPollerName, - options.pollerName, - options.namespace - )) - .then((config) => configUtil.decryptSecrets(config)) - .then((configs) => { - if (configs.length === 0) { - // unexpected, something went wrong - throw new errors.ObjectNotFoundInConfigError(`No System or System Poller with name '${sysOrPollerName}' configured`); - } - configs = configs.filter((c) => c.enable || includeDisabled); - return configs; - }); -} - -/** - * Get System Poller data for each provided configuration - * - * @param {Array} pollerConfigs - array of poller configurations - * @param {Boolean} [decryptSecrets = false] - whether decryption of secrets is needed - * - * @returns {Promise} resolved with pollers data - */ -function fetchPollersData(pollerConfigs, decryptSecrets) { - const promise = decryptSecrets ? configUtil.decryptSecrets(pollerConfigs) - : Promise.resolve(pollerConfigs); - - return promise - .then((decryptedConf) => promiseUtil.allSettled( - decryptedConf.map((pollerConf) => safeProcess(pollerConf, { requestFromUser: true })) - )) - .then((results) => promiseUtil.getValues(results)); // throws error if found it -} - -// config worker change event -configWorker.on('change', (config) => Promise.resolve() - .then(() => { - logger.debug('configWorker change event in systemPoller'); - return applyConfig(util.deepCopy(config)); - }) - .then(() => logger.debug(`${Object.keys(getPollerTimers()).length} system poller(s) running`)) - .catch((error) => logger.exception('Uncaught error during System Poller(s) configuration', error))); - -/** - * TEMP BLOCK OF CODE, REMOVE AFTER REFACTORING - */ -let processingEnabled = true; -let processingState = null; -let processingStatePromise = Promise.resolve(); - -/** @param {restWorker.ApplicationContext} appCtx - application context */ -function initialize(appCtx) { - if (appCtx.resourceMonitor) { - if (processingState) { - logger.debug('Destroying existing ProcessingState instance'); - processingState.destroy(); - } - processingState = appCtx.resourceMonitor.initializePState( - onResourceMonitorUpdate.bind(null, true), - onResourceMonitorUpdate.bind(null, false) - ); - processingEnabled = processingState.enabled; - onResourceMonitorUpdate(processingEnabled); - } else { - logger.error('Unable to subscribe to Resource Monitor updates!'); - } -} - -/** @param {boolean} enabled - true if processing enabled otherwise false */ -function onResourceMonitorUpdate(enabled) { - processingEnabled = enabled; - processingStatePromise = processingStatePromise.then(() => { - if (enabled) { - logger.warning('Enabling system poller(s).'); - return enablePollers(); - } - logger.warning('Temporarily disabling system poller(s).'); - return disablePollers(); - }) - .catch((error) => logger.exception(`Unexpected error on attempt to ${enabled ? 'enable' : 'disable'} system pollers:`, error)); -} - -/** - * Check if systemPoller(s) are running - * Toggled by monitor checks - * - * @returns {Boolean} - whether or not processing is enabled - */ - -function isEnabled() { - return processingEnabled; -} -/** - * TEMP BLOCK OF CODE END - */ - -module.exports = { - NoPollersError, - findSystemOrPollerConfigs, - fetchPollersData, - getPollersConfig, - getPollerTimers, - process, - safeProcess, - isEnabled, - initialize -}; diff --git a/src/lib/systemPoller/collector.js b/src/lib/systemPoller/collector.js new file mode 100644 index 00000000..c8c0beec --- /dev/null +++ b/src/lib/systemPoller/collector.js @@ -0,0 +1,254 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('../utils/assert'); +const collectorUtils = require('./utils'); +const defaultLogger = require('../logger'); +const Loader = require('./loader'); +const miscUtils = require('../utils/misc'); +const normalize = require('../normalize'); +const TaskQueue = require('../utils/taskQueue'); + +/** + * @module systemPoller/collector + * + * @typedef {import('./loader').Loader} Loader + * @typedef {import('../../logger').Logger} Logger + * @typedef {import('../../utils/taskQueue').TaskCallback} TaskCallback + */ + +/** + * System Stats Class + * + * @property {boolean} isCustom + * @property {Loader} loader + * @property {Logger} logger + * @property {object} properties + */ +class Collector { + /** + * Constructor + * + * @param {Loader} loader - data loader + * @param {object} properties - properties to load + * @param {object} options - data options + * @param {Logger} options.logger - parent logger + * @param {boolean} [options.isCustom = false] - `true` when properties are created by a user + * @param {number} [options.workers = 1] - number of workers + */ + constructor(loader, properties, { + isCustom = false, + logger = undefined, + workers = 1 + } = {}) { + assert.instanceOf(loader, Loader, 'loader'); + assert.object(properties, 'properties'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.boolean(isCustom, 'isCustom'); + assert.safeNumberBetweenInclusive(workers, 1, Number.MAX_SAFE_INTEGER, 'workers'); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + isCustom: { + value: isCustom + }, + loader: { + value: loader + }, + logger: { + value: logger.getChild(this.constructor.name) + }, + properties: { + value: properties + }, + workers: { + value: workers + } + }); + } + + /** @returns {boolean} true when instance is active */ + isActive() { + return !!this._stopPromise; + } + + /** + * Collect stats + * + * @returns {Promise} resolve with collected and processed stats + */ + collect() { + this.logger.debug('Starting stats collection'); + + let resolve; + this._stopPromise = (new Promise((done) => { + resolve = done; + })) + .then(() => { + this._stopPromise = null; + this.logger.verbose('Stopped'); + }); + + this._stopPromise.resolve = resolve; + this._stopPromise.stopRequested = false; + + return collect.call(this) + .catch(async (error) => { + if (this._stopPromise.inLoop !== true) { + this._stopPromise.resolve(); + } + await this.stop(); + + error.message = `Collector.collect error: ${error.message}`; + return Promise.reject(error); + }); + } + + /** @returns {Promise} resolved once stopped */ + stop() { + if (!this.isActive()) { + return Promise.resolve(); + } + + this.logger.debug('Stopping'); + this._stopPromise.stopRequested = true; + + return this._stopPromise; + } +} + +/** + * @this Collector + * + * @returns {CollectionResults} collected stats + */ +async function collect() { + /** @type {CollectionResults} */ + const results = { + errors: [], + stats: {} + }; + const taskQueue = new TaskQueue(processTask.bind(this, results), { + concurrency: this.workers, + logger: this.logger + }); + + this.logger.debug(`Task queue "${taskQueue.name}" configured with concurrency = ${taskQueue.concurrency}`); + + // load all properties to task queue + Object.entries(this.properties).forEach(([name, property]) => { + taskQueue.push({ + name, + property + }); + }); + + this._stopPromise.inLoop = true; + + // process is long enough, simply wait for results + while (taskQueue.size() && this._stopPromise.stopRequested === false) { + await miscUtils.sleep(100); + } + + this._stopPromise.inLoop = false; + + // wait till full stop + await taskQueue.stop(); + this._stopPromise.resolve(); + + return results; +} + +/** + * Load data for property + * + * @implements {TaskCallback} + * + * @param {CollectionResults} results - object to save processed data to + * @param {object} task - task + * @param {string} task.name - property name + * @param {object} task.property - property config to process + * @param {function} done - callback to call once task completed + * + * @returns {void} once task completed + */ +async function processTask(results, task, done) { + this.logger.verbose(`Loading data for property "${task.name}`); + + try { + await processProperty.call(this, results.stats, task); + } catch (error) { + error.message = `Collector.collect unexpected error on attemp to collect stats for "${task.name}" (${task.property.key}): ${error.message}`; + results.errors.push(error); + + if (this.isCustom) { + // for custom endpoints only, add an empty object to response, to show TS tried to load the endpoint + results.stats[task.name] = {}; + } + } finally { + done(); + } +} + +/** + * @param {obect} stats - collections of stats + * @param {object} task - task + * @param {string} task.name - property name + * @param {object} task.property - property config to process + * + * @returns {void} once task processed and the data save (if needed) + */ +async function processProperty(stats, task) { + const endpoint = collectorUtils.splitKey(task.property.key).rootKey; + + let data = await this.loader.loadEndpoint(endpoint, task.property.keyArgs); + data = data.data; + if (data && typeof data === 'object' && typeof data.items === 'undefined' + && Object.keys(data).length === 2 && data.kind && data.kind.endsWith('state')) { + data.items = []; + } + + if (task.property.normalize !== false) { + data = normalize.data(data, task.property.normalization); + } + if (!(typeof data === 'undefined' || (Array.isArray(data) && data.length === 0))) { + saveStatsData(stats, task.name, data, task.property); + } +} + +/** + * @param {object} stats - collection of stats + * @param {string} key - key to use to store stats + * @param {any} data - stats to save + * @param {object} property - stat config + */ +function saveStatsData(stats, key, data, property) { + if (property.structure && property.structure.parentKey) { + stats[property.structure.parentKey] = stats[property.structure.parentKey] || {}; + stats = stats[property.structure.parentKey]; + } + stats[key] = data; +} + +module.exports = Collector; + +/** + * @typedef {object} CollectionResults + * @property {Error[]} errors - errors + * @property {object} stats - collected data + */ diff --git a/src/lib/systemPoller/index.js b/src/lib/systemPoller/index.js new file mode 100644 index 00000000..7416ce6f --- /dev/null +++ b/src/lib/systemPoller/index.js @@ -0,0 +1,898 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-continue, no-nested-ternary, no-restricted-syntax, no-use-before-define */ + +const assert = require('../utils/assert'); +const constants = require('../constants'); +const errors = require('../errors'); +const Poller = require('./poller'); +const promiseUtil = require('../utils/promise'); +const Service = require('../utils/service'); +const util = require('../utils/misc'); +// TODO: remove once dataPipeline updated +const dataPipeline = require('../dataPipeline'); + +// TODO: use tracer to dump pollers stats for debugging + +const PRIVATES = new WeakMap(); + +/** + * @module systemPoller + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + * @typedef {import('../utils/config').Configuration} Configuration + * @typedef {import('../logger').Logger} Logger + * @typedef {import('./poller').OnReportCallback} OnReportCallback + * @typedef {import('./poller').Poller} Poller + * @typedef {import('./poller').PollerInfo} PollerInfo + * @typedef {import('../restAPI').Register} RegisterRestApiHandler + * @typedef {import('../restAPI').Config} RestApiConfig + * @typedef {import('../restAPI').RequestHandler} RestApiHandler + * @typedef {import('./poller').StatsReport} StatsReport + * @typedef {import('../utils/config').SystemPollerComponent} SystemPollerComponent + */ + +const EE_NAMESPACE = 'systemPoller'; + +/** + * System Poller Service Class + */ +class SystemPollerService extends Service { + /** @inheritdoc */ + async _onStart() { + /** @type {Map} */ + this._byPoller = new Map(); + + /** @type {Object} */ + this._hash2id = {}; + + /** @type {Object} */ + this._id2hash = {}; + + this._pstate = null; + + // start listening for events + this._registerEvents(); + } + + /** @inheritdoc */ + async _onStop() { + // stop receiving config updates + this._configListener.off(); + this._configListener = null; + + // stop receiving REST API updates + this._restApiListener.off(); + this._restApiListener = null; + + if (this._offRestApiHandlers) { + await this._offRestApiHandlers(); + } + + if (this._configUpdatePromise) { + this.logger.debug('Waiting for config routine to finish'); + await this._configUpdatePromise; + } + + await destroyAllPollers.call(this); + + if (this._pstate) { + this._pstate.destroy(); + } + + this._byPoller = null; + this._dataRouting = null; + this._hash2id = null; + this._id2hash = null; + + // stop public events + this._offMyEvents.off(); + this._offMyEvents = null; + } + + /** @returns {number} number of running CLASSIC pollers */ + get numberOfClassicPollers() { + return countPollers.call(this, (rec) => rec.classic); + } + + /** @returns {number} number of running PASSIVE pollers */ + get numberOfPassivePollers() { + return countPollers.call(this, (rec) => rec.passive); + } + + /** @returns {number} number of running DEMO pollers */ + get numberOfDemoPollers() { + return countPollers.call(this, (rec) => rec.demo); + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + // function to register subscribers + this._registerEvents = () => { + this._configListener = appEvents.on('config.change', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to configuration updates.'); + + this._restApiListener = appEvents.on('restapi.register', onRestApi.bind(this), { objectify: true }); + this.logger.debug('Subscribed to REST API updates.'); + + appEvents.on('resmon.pstate', (makePState) => { + this._pstate = makePState( + // on enable + updateProcessingState.bind(this), + // on disable + updateProcessingState.bind(this) + ); + }); + this.logger.debug('Subscribed to Resource Monitor updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { 'config.applied': 'config.applied' }, + 'config.decrypt', + 'config.getConfig', + 'config.getHash' + ]); + this.logger.debug('Registered public events.'); + + this._collectListener = appEvents.on(`*.${EE_NAMESPACE}.collect`, onCollectEvent.bind(this), { objectify: true }); + }; + } + + /** @returns {boolean} true when data processing enabled */ + isProcessingEnabled() { + return this._pstate ? this._pstate.enabled : true; + } +} + +/** + * @this SystemPollerService + * + * @param {function(rec: Record): boolean} predicate - callback that returns `true` when `rec` satisfies the criteria + * + * @returns {number} of pollers + */ +function countPollers(predicate) { + let count = 0; + if (this._byPoller) { + for (const rec of this._byPoller.values()) { + if (predicate(rec)) { + count += 1; + } + } + } + return count; +} + +/** + * @this SystemPollerService + * + * @param {SystemPollerComponent | SystemPollerComponent[]} config + * + * @returns {SystemPollerComponent | SystemPollerComponent[]} decrypted config + */ +async function decryptConfigs(config) { + return new Promise((resolve, reject) => { + this.ee.emitAsync('config.decrypt', util.deepCopy(config), (error, decrypted) => { + if (error) { + reject(error); + } else { + resolve(decrypted); + } + }); + }); +} + +/** + * @this SystemPollerService + * + * @param {Record} rec + * + * @returns {void} once deregistered + */ +function deregisterPoller(rec) { + PRIVATES.delete(rec.poller); + this._byPoller.delete(rec.poller); +} + +/** + * @this SystemPollerService + * + * @returns {void} once all pollers destroyed + */ +async function destroyAllPollers() { + this.logger.info('Destroying all registered pollers'); + + const promises = []; + for (const rec of this._byPoller.values()) { + promises.push(destroyPoller.call(this, rec)); + } + + await promiseUtil.allSettled(promises); +} + +/** + * @this SystemPollerService + * + * @param {Record} rec + * + * @returns {void} once poller destroyed + */ +async function destroyPoller(rec) { + try { + await rec.poller.destroy(); + } catch (error) { + this.logger.exception(`Uncaught error on attempt to destroy poller "${rec.name}":`, error); + } + this.logger.debug(`Poller "${rec.name}" destroyed!`); + deregisterPoller.call(this, rec); +} + +/** + * @this SystemPollerService + * + * @param {object} [options] + * @param {string} [options.hash] - poller's config hash + * @param {string} [options.name] - poller's name + * @param {string} [options.namespace] - namespace + * + * @returns {SystemPollerComponent[]} configs + */ +async function getConfigs({ hash = undefined, name = undefined, namespace = undefined } = {}) { + let id; + if (hash) { + id = this._hash2id[hash]; + assert.string(id, 'id'); + } + + return new Promise((resolve) => { + this.ee.emitAsync('config.getConfig', resolve, { + class: constants.CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME, + id, + name, + namespace + }); + }); +} + +/** + * @this SystemPollerService + * + * @param {SystemPollerComponent | SystemPollerComponent[]} config + * + * @returns {string | string[]} hashes + */ +async function getHashes(config) { + return new Promise((resolve, reject) => { + this.ee.emitAsync('config.getHash', config, (error, hashes) => { + if (error) { + reject(error); + } else { + resolve(hashes); + } + }); + }); +} + +/** + * Collects stats on demand + * + * @this SystemPollerService + * + * @param {string} id - poller ID + * @param {CollectCallback} callback + * + * @returns {void} once configuration applied + */ +function onCollectEvent(id, callback) { + (async () => { + // look up for config hash by ID + const hash = this._id2hash[id]; + let config = null; + + if (typeof hash !== 'undefined') { + const configs = await getConfigs.call(this, { hash }); + if (configs.length === 1) { + config = configs[0]; + } + } + if (config === null) { + throw new errors.ObjectNotFoundInConfigError(`System Poller with ID "${id}" not found`); + } + + const ret = passivePollerCollect.call(this, `${config.traceName}_Passive`, hash, config); + ret.rec.passive = true; + return ret.promise; + })() + .then( + (dataCtx) => callback(null, dataCtx), + (error) => callback(error, null) + ); +} + +/** + * Apply configuration for classic pollers (inverval > 0) + * + * NOTE: + * - Classic Pollers should store and use original config's hash to be able + * be identified for future changes + * - Demo pollers (via REST API) and Passive pollers (via .collect event) should use + * something else (e.g. hash + suffix or prefix) - those pollers are short-living + * object and should be removed immediately once died + * + * @this SystemPollerService + * + * @param {Configuration} newConfig + * + * @returns {void} once configuration applied + */ +async function onConfigEvent(newConfig) { + const applyConfig = async () => { + this.logger.debug('Config "change" event'); + + // TODO: remove once dataPipeline updated + this._dataRouting = newConfig.mappings; + + // reset mappings + this._hash2id = {}; + this._id2hash = {}; + + const configs = await decryptConfigs.call(this, await getConfigs.call(this)); + configs.forEach((c) => assert.config.systemPoller(c, 'systemPollerConfig')); + + const hashes = configs.length > 0 ? (await getHashes.call(this, configs)) : []; + hashes.forEach((h) => assert.string(h, 'systemPollerConfigHash')); + + /** + * Active should satisfy following criterias: + * - enabled + * - non-zero polling interval + * - should have receivers (Push Consumers) + */ + const activeHashes = hashes.filter((hash, idx) => configs[idx].enable + && configs[idx].interval > 0 + && Array.isArray(this._dataRouting[configs[idx].id])); + + const destroyPromises = []; + const runningHashes = []; + + for (const rec of this._byPoller.values()) { + if (rec.classic) { + // now it is ClassicRecord + if (activeHashes.includes(rec.hash)) { + runningHashes.push(rec.hash); + } else { + this.logger.debug(`Removing System Poller "${rec.name}". Reason - configuration updated.`); + // do not wait because it may take long time to terminated the process (e.g. slow response) + destroyPromises.push(destroyPoller.call(this, rec)); + } + } + } + + const startPromises = []; + for (let i = 0; i < hashes.length; i += 1) { + const hash = hashes[i]; + const config = configs[i]; + + this._hash2id[hash] = config.id; + this._id2hash[config.id] = hash; + + if (runningHashes.includes(hash) || !activeHashes.includes(hash)) { + this.logger.debug(`No configuration changes for "${config.traceName}"`); + // poller exists and config didn not changed or poller config is not active + continue; + } + + this.logger.info(`Starting System Poller "${config.traceName}"`); + + const cb = processReportSafe.bind(this, { + dataActions: config.dataOpts.actions, + ignoreEmptyReport: true, + noConsumers: false + }); + + const ret = startPoller.call(this, cb, config.traceName, hash, { onePassOnly: false }); + // now it is ClassicRecord + ret.rec.classic = true; + + startPromises.push(ret.promise); + } + promiseUtil.getValues(await promiseUtil.allSettled(startPromises)); + + this.logger.info(`${activeHashes.length} System Poller(s) running`); + this.logger.info(`${this.numberOfDemoPollers} DEMO System Poller(s) registered`); + this.logger.info(`${this.numberOfPassivePollers} Passive System Poller(s) registered`); + }; + + try { + this._configUpdatePromise = applyConfig(); + await this._configUpdatePromise; + } catch (error) { + this.logger.exception('Error caught on attempt to apply configuration to System Poller Service:', error); + } finally { + this._configUpdatePromise = null; + // - emit in any case to show we are done with config processing + // - do not wait for results + this.ee.safeEmitAsync('config.applied'); + } +} + +/** + * Apply REST API configuration + * + * @this SystemPollerService + * + * @param {RegisterRestApiHandler} register - register handler + * @param {RestApiConfig} config - config + */ +async function onRestApi(register, config) { + if (config.debug) { + // previous handler (if registered) destroyed already + const requestHandler = makeRequestHandler.call(this); + + const offs = [ + register(['GET'], '/systempoller', requestHandler), + register(['POST'], '/systempoller/:system/:poller', requestHandler), + register(['GET'], '/namespace/:namespace/systempoller', requestHandler), + register(['POST'], '/namespace/:namespace/systempoller/:system/:poller', requestHandler) + ]; + this._offRestApiHandlers = () => promiseUtil.allSettled(offs.map((off) => off())); + } +} + +/** + * @this SystemPollerService + * + * @param {string} name - name to set to newly created System Poller + * @param {string} hash - hash + * @param {SystemPollerComponent} config - config + * + * @returns {Promise} with dataCtx once collected + */ +function passivePollerCollect(name, hash, config) { + const resolvers = promiseUtil.withResolvers(); + const ret = startPoller.call(this, (error, poller, results) => { + if (error) { + resolvers.reject(error); + } else { + resolvers.resolve({ poller, results }); + } + }, name, hash, { onePassOnly: true }); + + return { + rec: ret.rec, + promise: promiseUtil.allSettled([ + // reject second promise if start failed to avoid dead-lock + ret.promise.then( + (error) => error && resolvers.reject(error), + resolvers.reject + ), + resolvers.promise + ]) + .then((results) => { + destroyPoller.call(this, ret.rec); + results = promiseUtil.getValues(results); + return processReport.call(this, { + dataActions: config.dataOpts.actions, + ignoreEmptyReport: false, + noConsumers: true + }, null, results[1].poller, results[1].results); + }) + }; +} + +/** + * @this SystemPollerService + * + * @param {object} options + * @param {array} options.dataActions + * @param {boolean} [options.ignoreEmptyReport = false] + * @param {boolean} options.noConsumers + * @param {object} error - error if any caught + * @param {Poller} poller - Poller instance + * @param {CollectionResults} report - stats report or null when error caught + * + * @returns {DataCtx | void} once report processed + */ +async function processReport({ + dataActions = [], + ignoreEmptyReport = false, + noConsumers = false +}, error, poller, report) { + assert.instanceOf(poller, Poller, 'poller'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + const rec = this._byPoller.get(poller); + + if (error) { + this.logger.exception(`Ignoring stats report from "${rec.name}" due error:'`, error); + return Promise.resolve(); + } + if (Object.keys(report.stats).length === 0 && ignoreEmptyReport) { + this.logger.debug(`Ignoring empty stats report from "${rec.name}"`); + return Promise.resolve(); + } + + const pollerID = this._hash2id[rec.hash]; + assert.string(pollerID, 'pollerID'); + + let dataCtx = { + data: Object.assign(report.stats, { + telemetryEventCategory: constants.EVENT_TYPES.SYSTEM_POLLER, + telemetryServiceInfo: { + pollingInterval: report.metadata.pollingInterval, + cycleStart: (new Date(report.metadata.cycleStart)).toISOString(), + cycleEnd: (new Date(report.metadata.cycleEnd)).toISOString() + } + }), + destinationIds: (!noConsumers && this._dataRouting[pollerID]) || [], + isCustom: report.metadata.isCustom, + sourceId: pollerID, + type: constants.EVENT_TYPES.SYSTEM_POLLER + }; + + dataCtx = await dataPipeline.process( + dataCtx, + constants.DATA_PIPELINE.PUSH_EVENT, + null, + { + actions: dataActions, + catchErrors: !noConsumers, + deviceContext: report.metadata.deviceContext + } + ); + + this.ee.safeEmit('report', dataCtx); + return dataCtx; +} + +/** + * @see processReport + */ +async function processReportSafe() { + try { + return await processReport.apply(this, arguments); + } catch (error) { + this.logger.exception('Unable to process stats report', error); + } + return undefined; +} + +/** + * @this SystemPollerService + * + * @param {Record} rec + * + * @returns {void} once registered + */ +function registerPoller(rec) { + this._byPoller.set(rec.poller, rec); +} + +/** + * @this SystemPollerService + * + * @param {OnReportCallback} cb + * @param {string} name - polle's name + * @param {string} hash - config hash + * @param {object} [pollerOptions] - poller configuration + * + * @returns {{promise: Promise, rec: Record}} poller's record and promise resolved with + * null once started or with error (not rejected) when failed to start + */ +function startPoller(cb, name, hash, pollerOptions = {}) { + assert.safeNumberGrEq(arguments.length, 3, 'arguments.length'); + assert.function(cb, 'callback'); + assert.string(name, 'name'); + assert.string(hash, 'hash'); + assert.oneOfAssertions( + () => assert.object(pollerOptions, 'pollerOptions'), + () => assert.emptyObject(pollerOptions, 'pollerOptions') + ); + + const rec = { + hash, + name, + poller: new Poller( + pollerHelpers.buildManagerProxy.call(this), + cb, + { + logger: this.logger.getChild(`Poller[${name}]`), + ...pollerOptions + } + ) + }; + + registerPoller.call(this, rec); + + return { + rec, + promise: Promise.resolve() + .then(async () => { + await rec.poller.start(); + return null; + }) + .catch((error) => { + deregisterPoller.call(this, rec); + this.logger.exception(`Uncaught error on attempt to start "${rec.name}":`, error); + return error; + }) + }; +} + +/** + * @this SystemPollerService + */ +function updateProcessingState() { + if (!this._byPoller) { + return; + } + + const enabled = this.isProcessingEnabled(); + + for (const rec of this._byPoller.values()) { + if (!rec.classic) { + continue; + } + if (enabled) { + this.logger.warning(`Enabling system poller "${rec.name}"`); + rec.poller.start() + .catch((error) => this.logger.debugException(`Uncaught error on attempt to start system poller "${rec.name}"`, error)); + } else { + this.logger.warning(`Temporarily disabling system poller "${rec.name}"`); + rec.poller.stop() + .catch((error) => this.logger.debugException(`Uncaught error on attempt to stop system poller "${rec.name}"`, error)); + } + } +} + +const pollerHelpers = { + /** + * @this SystemPollerService + * + * @returns {ManagerProxy} proxy object + */ + buildManagerProxy() { + const proxy = {}; + Object.defineProperties(proxy, { + cleanupConfig: { + value: pollerHelpers.cleanupConfig.bind(this) + }, + getConfig: { + value: pollerHelpers.getConfig.bind(this) + } + }); + return proxy; + }, + + /** + * @this SystemPollerService + * + * @param {Poller} poller + * + * @returns {void} once poller's cached config removed + */ + async cleanupConfig(poller) { + // demo pollers allowed too + assert.instanceOf(poller, Poller, 'poller'); + PRIVATES.delete(poller); + }, + + /** + * @this SystemPollerService + * + * @param {Poller} poller + * @param {boolean} decrypt + * + * @returns {SystemPollerComponent} poller's configuration + */ + async getConfig(poller, decrypt) { + assert.instanceOf(poller, Poller, 'poller'); + assert.boolean(decrypt, 'decrypt'); + assert.assert(this._byPoller.has(poller), 'pollerRegistered'); + + if (!PRIVATES.has(poller)) { + const config = await getConfigs.call(this, { hash: this._byPoller.get(poller).hash }); + assert.assert(config.length === 1, 'pollerConfig', 'should return a config object by hash'); + + PRIVATES.set(poller, { + config: config[0], + decrypted: false + }); + } + + const config = PRIVATES.get(poller); + if (decrypt && config.decrypted === false) { + config.config = await decryptConfigs.call(this, config.config); + config.decrypted = true; + } + + assert.config.systemPoller(config.config, 'systemPollerConfig'); + return config.config; + } +}; + +/** + * @this SystemPollerService + * + * @returns {RestApiHandler} + */ +function makeRequestHandler() { + const service = this; + /** + * @implements {RestApiHandler} + */ + return Object.freeze({ + /** + * @param {object} [options] - options + * @param {string} [options.name] - poller's name + * @param {string} [options.namespace] - namespace + * @param {string} [options.system] - system's name + * + * @returns {SystemPollerComponent[]} pollers + * @throws {ObjectNotFoundInConfigError} error when unable to find config + */ + async _getConfigs({ namespace = undefined, name = undefined, system = undefined }) { + let configs = (await getConfigs.call(service, { name, namespace })); + + if (system) { + configs = configs.filter( + (c) => c.systemName === system + ); + if (configs.length === 0) { + throw new errors.ObjectNotFoundInConfigError('System or System Poller declaration not found'); + } + } + return configs; + }, + + /** @returns {Record[]} pollers */ + async _getPollers(req) { + const uriParams = req.getUriParams(); + const queryParams = req.getQueryParams(); + + let pollers = Array.from(service._byPoller.values()); + + let namespace = uriParams.namespace; + if (!namespace && queryParams.all !== 'true') { + namespace = constants.DEFAULT_UNNAMED_NAMESPACE; + } + + if (namespace || uriParams.system) { + const hashes = (await this._getConfigs({ namespace })) + .map((c) => service._id2hash[c.id]) // search for config hash by config ID + .filter((h) => h); // filter empty results + + // filter by config hash - allows to include demo and regular pollers that shares a config + pollers = pollers.filter((rec) => hashes.includes(rec.hash)); + } + + return pollers; + }, + + /** + * Responds to user with states for all DEMO pollers (within namespace) + * + * Query args: + * - demo=true - return demo pollers only + * - all=true - return all matching pollers despite namespace (when no namespace set) + */ + async _getStates(req, res) { + let pollers = await this._getPollers(req); + const numberOfPollersTotal = pollers.length; + const numberOfDemoPollers = pollers.reduce((acc, rec) => acc + (rec.demo ? 1 : 0), 0); + + if (req.getQueryParams().demo === 'true') { + pollers = pollers.filter((rec) => rec.demo); + } + + res.code = 200; + res.contentType = 'application/json'; + res.body = { + code: res.code, + numberOfPollersTotal, + numberOfPollers: numberOfPollersTotal - numberOfDemoPollers, + numberOfDemoPollers, + states: pollers.map((rec) => Object.assign({ + name: rec.name, + type: rec.passive ? 'passive' : (rec.demo ? 'demo' : 'classic') + }, rec.poller.info())) + }; + }, + + /** Responds to user once demo poller created */ + async _startDemo(req, res) { + const uriParams = req.getUriParams(); + let config = await this._getConfigs({ + name: uriParams.poller, + namespace: uriParams.namespace || constants.DEFAULT_UNNAMED_NAMESPACE, + system: uriParams.system + }); + + assert.assert(config.length === 1, 'config', 'should not have multiple configurations!'); + + config = config[0]; + const hash = service._id2hash[config.id]; + assert.defined(hash, 'hash'); + + let demoRec; + + for (const rec of service._byPoller.values()) { + if (rec.hash === hash && rec.demo) { + demoRec = rec; + break; + } + } + + if (!demoRec) { + const ret = passivePollerCollect.call(service, `${config.traceName}_DEMO`, hash, config); + demoRec = ret.rec; + demoRec.demo = true; + demoRec.promise = ret.promise; + } else { + service.logger.debug(`${demoRec.name} is running already. Chaining reuqest to existing promise and waiting for results`); + } + + res.code = 200; + res.contentType = 'application/json'; + res.body = (await demoRec.promise).data; + }, + + /** @inheritdoc */ + async handle(req, res) { + if (req.getMethod() === 'GET') { + return this._getStates(req, res); + } + if (req.getMethod() === 'DELETE') { + return this._deleteDemo(req, res); + } + return this._startDemo(req, res); + }, + name: 'System Poller Service' + }); +} + +module.exports = SystemPollerService; + +/** + * @callback CollectCallback + * @param {Error} error - error or null + * @param {DataCtx} dataCtx - collected + */ +/** + * @typedef {object} ManagerProxy + * @property {async function(poller: Poller)} cleanupConfig + * @property {async function(poller: Poller, decrypt: boolean): SystemPollerComponent} getConfig + */ +/** + * @typedef {object} Record + * @property {string} hash - config hash + * @property {string} name - Poller's name + * @property {Poller} poller - Poller instance + */ +/** + * @typedef {Record} ClassicRecord + * @property {boolean} classic - set to `true` if poller is long-living instance + */ +/** + * @typedef {Record} PassiveRecord + * @property {boolean} passive - set to `true` if poller is short-living passive instance + */ +/** + * @typedef {Record} DemoRecord + * @property {boolean} demo - set to `true` if poller is short-living demo instance + */ diff --git a/src/lib/systemPoller/loader.js b/src/lib/systemPoller/loader.js new file mode 100644 index 00000000..87f8ed5e --- /dev/null +++ b/src/lib/systemPoller/loader.js @@ -0,0 +1,584 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const querystring = require('querystring'); + +const assert = require('../utils/assert'); +const constants = require('../constants'); +const defaultLogger = require('../logger'); +const deviceUtil = require('../utils/device'); +const promiseUtil = require('../utils/promise'); +const retryPromise = require('../utils/promise').retry; +const TaskQueue = require('../utils/taskQueue'); +const util = require('../utils/misc'); + +/** + * @module systemPoller/loader + * + * @typedef {import('../../logger').Logger} Logger + * @typedef {import('../../utils/config').Connection} Connection + * @typedef {import('../../utils/config').Credentials} Credentials + * @typedef {import('../../utils/taskQueue').TaskInfo} TaskInfo + * @typedef {import('../../utils/taskQueue').TaskQueue} TaskQueue + * @typedef {import('../../utils/taskQueue').TaskCallback} TaskCallback + */ + +/** + * F5 BIG-IP REST API Endpoint Loader + * + * @property {number} chunkSize + * @property {Connection} connection + * @property {Credentials} credentials + * @property {string} host + * @property {Logger} logger + */ +class Loader { + /** + * Constructor + * + * @param {string} host - host + * @param {object} options - options + * @param {Logger} options.logger - parent logger + * @param {HttpAgent} [options.agent] - HTTP agent + * @param {number} [options.chunkSize = constants.SYSTEM_POLLER.CHUNK_SIZE] - number of items in response + * @param {Credentials} [options.credentials] - F5 Device credentials + * @param {null | string} [options.credentials.token] - F5 Device authorization token + * @param {Connection} [options.connection] - F5 Device connection settings + * @param {number} [options.workers = constants.SYSTEM_POLLER.WORKERS] - number of workers + */ + constructor(host, { + agent = undefined, + chunkSize = constants.SYSTEM_POLLER.CHUNK_SIZE, + connection = undefined, + credentials = undefined, + logger = undefined, + workers = constants.SYSTEM_POLLER.WORKERS + } = {}) { + assert.string(host, 'target host'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.safeNumberBetweenInclusive(chunkSize, 1, Number.MAX_SAFE_INTEGER, 'chunkSize'); + assert.safeNumberBetweenInclusive(workers, 1, Number.MAX_SAFE_INTEGER, 'workers'); + assert.bigip.credentials(host, credentials, 'credentials'); + + if (connection) { + assert.bigip.connection(connection, 'connection'); + } + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + _agent: { + value: agent + }, + chunkSize: { + value: chunkSize + }, + connection: { + value: util.deepFreeze(util.deepCopy(connection)) + }, + credentials: { + value: util.deepCopy(credentials || {}) + }, + host: { + value: host + }, + logger: { + value: logger.getChild(`${this.constructor.name}[${host}]`) + } + }); + Object.defineProperties(this, { + _taskQueue: { + value: new TaskQueue(processTask.bind(this), { + concurrency: workers, + logger: this.logger, + usePriority: true + }) + } + }); + + this.logger.debug(`Task queue "${this._taskQueue.name}" configured with concurrency = ${this._taskQueue.concurrency}`); + + this._endpoints = {}; + this.eraseCache(); + } + + /** @returns {void} once successfully authenticated */ + async auth() { + if (typeof this.credentials.token !== 'undefined') { + return; + } + // in case of optimization, replace with Object.assign + this.credentials.token = (await deviceUtil.getAuthToken( + this.host, + this.credentials.username, + this.credentials.passphrase, + cloneConnectionOptions.call(this) + )).token; + util.deepFreeze(this.credentials); + } + + /** Erases cached response */ + eraseCache() { + this._cache = {}; + } + + /** + * Sending request to REST API endpoint and parses response + * + * @param {string} endpoint - endpoint name/key to fetch data from + * @param {object} [options] - function options + * @param {object} [options.replaceStrings] - key/value pairs that replace matching strings in request body + * + * @returns {FetchedData} FetchedData object once response processed + */ + async loadEndpoint(endpoint, { replaceStrings = undefined } = {}) { + let endpointObj = this._endpoints[endpoint]; + assert.defined(endpointObj, 'endpointObj'); + + // copy top-level properties to avoid mutation of original object + endpointObj = Object.assign({}, endpointObj); + if (endpointObj.pagination) { + endpointObj.query = Object.assign({ + $top: this.chunkSize + }, endpointObj.query || {}); + } + + if (endpointObj.query) { + const query = querystring.stringify(endpointObj.query); + endpointObj.path = `${endpointObj.path}?${query}`; + delete endpointObj.query; + } + + if (typeof replaceStrings === 'object') { + endpointObj.body = replaceBodyVars(endpointObj.body, replaceStrings); + } + + /** + * works well for custom and default endpoints: + * - ignoreCached !== false always evaluates to `true` for custom endpoints + */ + const allowCachedData = !( + endpointObj.pagination + || !!endpointObj.ignoreCached !== false + || endpointObj.body + ); + let response; + + if (allowCachedData && typeof this._cache[endpointObj.path] !== 'undefined') { + response = this._cache[endpointObj.path]; + } + if (response && typeof response.data !== 'undefined') { + this.logger.verbose(`Using cached data for "${endpoint}"`); + // cached response + return response.data; + } + + response = response || {}; + if (!response.promise) { + response.promise = getAndExpandData.call(this, endpointObj) + .then((data) => { + delete response.promise; + response.data = data; + }) + .catch((error) => { + if (allowCachedData) { + delete this._cache[endpointObj.path]; + } + error.message = `Unable to get response from endpoint "${endpoint}": ${error.message}`; + return Promise.reject(error); + }); + + if (allowCachedData) { + this._cache[endpointObj.path] = response; + } + } else { + this.logger.verbose(`Using cached data for "${endpoint}"`); + } + + await response.promise; + return response.data; + } + + /** + * Set endpoints definition + * + * @param {Endpoint[]} newEndpoints - list of endpoints to add + */ + setEndpoints(newEndpoints) { + assert.array(newEndpoints); + + this._endpoints = {}; + newEndpoints.forEach((endpoint) => { + assert.bigip.endpoint(endpoint, 'endpoint'); + // if 'name' presented then use it as unique ID + // otherwise use path prop + const key = endpoint.name || endpoint.path; + if (typeof this._endpoints[key] !== 'undefined') { + this.logger.debug(`Endpoint with key "${key}" exists already!`); + } + this._endpoints[key] = endpoint; + }); + } +} + +/** + * @this Loader + * + * @returns {Connection | undefined} copied connection options + */ +function cloneConnectionOptions() { + const opts = this.connection ? Object.assign({}, this.connection) : {}; + opts.agent = this._agent; + return opts; +} + +/** + * @this Loader + * + * @param {Endpoint} endpointObj - endpoint object + * @param {FetchedData} data - fetched data + * + * @returns {FetchedData[]} array of FetchedData + */ +async function expandReferences(endpointObj, data) { + const promises = []; + const dataItems = data.data.items; + + if (endpointObj.expandReferences && dataItems && Array.isArray(dataItems) && dataItems.length) { + // for now let's just support a single reference + const referenceKey = Object.keys(endpointObj.expandReferences)[0]; + const referenceObj = endpointObj.expandReferences[referenceKey]; + + for (let i = 0; i < dataItems.length; i += 1) { + const item = dataItems[i][referenceKey]; + if (item && item.link) { + let referenceEndpoint = getURIPath(item.link); + // Process '/stats' endpoint first, before modifying referenceEndpoint url + if (referenceObj.includeStats) { + promises.push(promisifyTask.call(this, { + uri: `${referenceEndpoint}/stats`, + options: { name: i, refKey: referenceKey } + })); + } + if (referenceObj.endpointSuffix) { + referenceEndpoint = `${referenceEndpoint}${referenceObj.endpointSuffix}`; + } + promises.push(promisifyTask.call(this, { + uri: referenceEndpoint, + options: { name: i, refKey: referenceKey } + })); + } + } + } + + return promiseUtil.getValues(await promiseUtil.allSettled(promises)); +} + +/** + * Fetch stats for each item + * + * @this Loader + * + * @param {Endpoint} endpointObj - endpoint object + * @param {FetchedData} data - data + * + * @returns {FetchedData[]} array of FetchedData + */ +async function fetchStats(endpointObj, data) { + const promises = []; + const dataItems = data.data.items; + + if (endpointObj.includeStats && dataItems && Array.isArray(dataItems) && dataItems.length) { + for (let i = 0; i < dataItems.length; i += 1) { + const item = dataItems[i]; + // check for selfLink property + if (item.selfLink) { + promises.push(promisifyTask.call(this, { + uri: `${getURIPath(item.selfLink)}/stats`, + options: { + name: i + } + })); + } + } + } + + return promiseUtil.getValues(await promiseUtil.allSettled(promises)); +} + +/** + * @this Loader + * + * @param {Endpoint} endpointObj - endpoint object + * @param {string} path - URI path to get data from + * + * @returns {Promise} FetchedData for endpointObj + */ +async function getAndExpandData(endpointObj, path) { + // baseData in this method is the data fetched from endpointObj.path + const baseData = await promisifyTask.call(this, { + uri: path || endpointObj.path, + options: endpointObj, + priority: constants.TASK.HIGH_PRIORITY + }); + + const nextLink = baseData.data.nextLink; + delete baseData.data.nextLink; + + let response = await expandReferences.call(this, endpointObj, baseData); + substituteData(baseData, response, false); + + response = await fetchStats.call(this, endpointObj, baseData); + substituteData(baseData, response, true); + + if (!nextLink) { + return baseData; + } + const nextBaseData = await getAndExpandData.call(this, endpointObj, getURIPath(nextLink, true)); + + if (typeof baseData.data.entries === 'object' && typeof nextBaseData.data.entries === 'object' + && !Array.isArray(baseData.data.entries) && !Array.isArray(nextBaseData.data.entries)) { + Object.assign(baseData.data.entries, nextBaseData.data.entries); + } else if (Array.isArray(baseData.data.items) && Array.isArray(nextBaseData.data.items)) { + baseData.data.items.push(...nextBaseData.data.items); + } else { + this.logger.warning(`Unable to merge additional data (nextLink=${nextLink})`); + } + return baseData; +} + +/** + * Get URI path + * + * @param {string} uri - full URI + * @param {boolean} [keepQueryStr = false] - retain query string as part of the path + * + * @returns {string} URI path + */ +function getURIPath(uri, keepQueryStr = false) { + assert.string(uri, 'uri'); + assert.boolean(keepQueryStr, 'keepQueryStr'); + + uri = uri.replace('https://localhost', ''); + return keepQueryStr ? uri : uri.split('?')[0]; +} + +/** + * Get data for specific endpoint + * + * @implements {TaskCallback} + * @this Loader + * + * @param {EndpointTask} task - current task + * @param {function} done - callback to call once done + * @param {TaskInfo} info - task's metadata + */ + +async function processTask(task, done, info) { + const options = task.options; + const parseDuplicateKeys = options.parseDuplicateKeys === true; + + const retryOpts = { + maxTries: 3, + backoff: 100 + }; + + this.logger.verbose(`Sending query to "${task.uri}" (${info.taskID})`); + + return retryPromise(() => { + const httpOptions = cloneConnectionOptions.call(this); + httpOptions.credentials = this.credentials; + + if (parseDuplicateKeys) { + httpOptions.rawResponseBody = true; + } + if (options.body) { + httpOptions.method = 'POST'; + httpOptions.body = options.body; + if (typeof httpOptions.body !== 'object') { + httpOptions.json = false; + } + } + + return deviceUtil.makeDeviceRequest(this.host, task.uri, httpOptions); + }, retryOpts) + .then((data) => { + if (parseDuplicateKeys) { + data = util.parseJsonWithDuplicateKeys(data.toString()); + } + const ret = { + name: options.name !== undefined ? options.name : task.uri, + data + }; + if (options.refKey) { + ret.refKey = options.refKey; + } + task.cb(null, ret); + }) + .catch((err) => task.cb(err, null)) + .then(done); +} + +/** + * @param {EndpointTask} task + * + * @returns {Promise} resolve once task completed + */ +function promisifyTask(task) { + return new Promise((resolve, reject) => { + assert.string(task.uri, 'task.uri'); + assert.object(task.options, 'task.options'); + + if (typeof task.priority === 'undefined') { + task.priority = constants.TASK.LOW_PRIORITY; + } else { + assert.safeNumberGrEq(task.priority, 0, 'task.priority'); + } + + task.cb = (err, ret) => { + if (err) { + reject(err); + } else { + resolve(ret); + } + }; + this._taskQueue.push(task); + }); +} + +/** + * Substitute data + * + * @param {FetchedData} baseData - base data + * @param {FetchedData[]} dataArray - array of data to use for substitution + * @param {boolean} shallowCopy - true if shallow copy required else original object will be used + */ +function substituteData(baseData, dataArray, shallowCopy) { + if (!dataArray.length) { + return; + } + const baseDataItems = baseData.data.items; + dataArray.forEach((data) => { + try { + let dataToSubstitute; + if (shallowCopy === true) { + dataToSubstitute = Object.assign(data.data, baseDataItems[data.name]); + } else { + dataToSubstitute = data.data; + } + if (data.refKey) { + // if this is the first time substituting data, overwrite the containing object with data + // e.g. + // itemsRef: { + // link: 'http://objLink/objItems', + // isSubcollection: true + // } + // will become: + // itemsRef: { + // objItemProp1: 123 //data from link + // } + if (baseDataItems[data.name][data.refKey].link) { + baseDataItems[data.name][data.refKey] = dataToSubstitute; + } else { + // otherwise if same object has been previously substituted + // and we're merging new set of props from a different link (e.g. objItems/stats) + // then copy over the properties of the new dataToSubstitute + // e.g. + // itemsRef: { + // objItemProp1: 123 + // objItemProp2: true + // } + Object.assign(baseDataItems[data.name][data.refKey], dataToSubstitute); + } + } else { + baseDataItems[data.name] = dataToSubstitute; + } + } catch (e) { + // just continue + } + }); +} + +/** + * Replace variables in body with values + * + * @param {object | string} body - request body + * @param {object} keys - keys/vars to replace + * + * @returns {object | string} copy of data with replaced pieces + */ +function replaceBodyVars(body, keys) { + assert.oneOfAssertions( + () => assert.object(body, 'body'), + () => assert.string(body, 'body') + ); + assert.object(keys, 'keys'); + + let isObject = false; + if (typeof body !== 'string') { + isObject = true; + body = JSON.stringify(body); + } + Object.keys(keys).forEach((key) => { + body = body.replace(new RegExp(key), keys[key]); + }); + if (isObject) { + body = JSON.parse(body); + } + return body; +} + +module.exports = Loader; + +/** + * Options to use to expand reference + * + * @typedef {object} ExpandReferencesOpts + * @property {string} [endpointSuffix] - URI suffix to use to modify link + * @property {boolean} [includeStats] - include response from /stats + */ +/** + * References to expand + * + * @typedef {object.} */ +const STATES = { + COLLECT: { + allowTermination: true, + next: 'SEND_REPORT', + onRun() { + return collectStats.call(this); + }, + onSuccess() { + this.logger.debug('Successfully collected stats'); + this.storage.stats.statsCollected += 1; + } + }, + DONE: { + next: 'SCHEDULE', + onRun() { + this.logger.debug(`Successfully collected and processed stats. Polling Cycle duration - ${getDuration.call(this)} sec.`); + this.storage.stats.cyclesCompleted += 1; + return true; + } + }, + FAILED: { + next: 'SCHEDULE', + async onRun() { + this.state.endTimestamp = Date.now(); + + this.state.errorMsg = `${this.mostRecentError}`; + this.logger.exception(`System Poller cycle failed due task error after ${getDuration.call(this)} sec.:`, this.mostRecentError); + + try { + this.callback(this.mostRecentError, this.poller, null); + } catch (cbError) { + this.logger.exception('Uncaught error on attempt to call callback:', cbError); + } + + this.mostRecentError = undefined; + + return true; + } + }, + SCHEDULE: { + allowTermination: true, + next: 'WAITING', + onRun() { + return scheduleNextExecution.call(this); + }, + onSuccess() { + this.logger.debug('Successfully scheduled next execution date'); + } + }, + SEND_REPORT: { + next: 'DONE', + async onRun() { + this.state.endTimestamp = Date.now(); + + // send the report and remove reference to it + sendReport.call(this, this.stats); + this.stats = null; + + this.state.succeed = true; + this.storage.stats.statsProcessed += 1; + this.state.errorMsg = ''; + + return true; + }, + onSuccess() { + this.logger.debug('Successfully processed stats report'); + } + }, + WAITING: { + allowTermination: true, + next: 'COLLECT', + onRun() { + return waitTillExecDate.call(this); + }, + onSuccess() { + this.logger.debug('Starting polling cycle'); + this.state.startTimestamp = Date.now(); + } + } +}; + +Object.keys(STATES).forEach((key) => { + STATES[key] = Object.assign(String(key), STATES[key]); +}); + +/** + * System Poller Class + * + * @property {function} info - current info + * @property {boolean} onePassOnly - use the schedule provided by the config + */ +class Poller extends Service { + /** + * @param {ManagerProxy} manager + * @param {OnReportCallback} callback + * @param {object} options + * @param {Logger} options.logger - parent logger + * @param {boolean} [options.onePassOnly = false] - try to collect stats only once, scheduling interval ignored + */ + constructor(manager, callback, { + logger = undefined, + onePassOnly = false + }) { + assert.exist(manager, 'manager'); + assert.function(callback, 'callback'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.boolean(onePassOnly, 'onePassOnly'); + + super(logger); + + // create now and override later to allow to call .info() + let internals = makeInternals.call(this, manager, callback); + let loopError = false; + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + info: { + value: () => getInfo.call(internals) + }, + onePassOnly: { + value: !!onePassOnly + } + }); + + // no restarts in demo-mode + this.restartsEnabled = !this.onePassOnly; + + /** @inheritdoc */ + this._onStart = async (onFatalError) => { + internals = makeInternals.call(this, manager, callback); + loopError = false; + + // kick-off main activity loop + mainLoop.call(internals) + .catch(async (error) => { + // the loop is dead + internals.terminatedCb(); + + loopError = true; + + this.logger.exception('Terminating system poller due uncaught error:', error); + + if (this.onePassOnly) { + await this.destroy(); + } else { + onFatalError(error); + } + }); + }; + + /** @inheritdoc */ + this._onStop = async () => { + // set flag to let the loop know + internals.terminated = true; + + // interrupt sleep routine + if (internals.sleepPromise) { + internals.sleepPromise.cancel(new Error('terminated')); + } + + if (loopError) { + // the loop is dead already + return; + } + // wait till terminated + await internals.stopPromise; + }; + } +} + +/** + * @this {Internals} + * + * @returns {void} once exited + */ +async function mainLoop() { + initStorageData.call(this); + initPollingState.call(this); + + while (true) { + const currentState = this.state.lastKnownState; + let doTaskError; + let nextState; + + try { + assert.function(currentState.onRun, `${currentState}.onRun`); + assert.string(currentState.next, `${currentState}.next`); + assert.defined(STATES[currentState.next], 'STATES[currentState.next]'); + + const succeed = await currentState.onRun.call(this); + assert.boolean(succeed, 'onRun'); + + if (succeed === true) { + if (typeof currentState.onSuccess === 'function') { + await currentState.onSuccess.call(this); + } + + nextState = STATES[currentState.next]; + + this.logger.debug(`Successfully completed "${currentState}" step`); + } else { + const msg = `Step "${currentState}" failed!`; + this.logger.debug(msg); + throw new Error(msg); + } + } catch (error) { + // error might be related to initialization, not the actual process + doTaskError = error; + } + + this.mostRecentError = doTaskError || undefined; + + if (this.mostRecentError) { + nextState = STATES.FAILED; + + if (currentState === STATES.FAILED) { + // unable to process error, FAILED.onRun failed + this.logger.exception(`Uncaught error on attept to process "${nextState}" state:`, this.mostRecentError); + nextState = STATES.SCHEDULE; + } + } + + assert.object(nextState, 'nextState'); + + if (nextState === STATES.SCHEDULE) { + // task done + const prevExecDate = this.state.execDate; + addHistoryRecord.call(this); + + if (this.poller.onePassOnly) { + this.logger.debug('Terminating system poller'); + nextState = currentState; + this.terminated = true; + break; + } else { + removePollingState.call(this); + initPollingState.call(this, prevExecDate); + } + } + + this.state.lastKnownState = nextState; + this.logger.debug(`Transitioning from step "${currentState}" to "${nextState}"`); + + if (this.terminated && this.state.lastKnownState.allowTermination === true) { + break; + } + } + + await cleanupSensitiveData.call(this); + + if (this.httpAgent) { + this.httpAgent.destroy(); + this.httpAgent = undefined; + } + + this.terminatedCb(); +} + +/** + * @this {Internals} + * + * @returns {void} once record added + */ +function addHistoryRecord() { + this.storage.history.push(makeHistoryRecord.call(this)); + + if (this.storage.history.length > constants.SYSTEM_POLLER.MAX_HISTORY_LEN) { + this.storage.history = this.storage.history.slice( + this.storage.history.length - constants.SYSTEM_POLLER.MAX_HISTORY_LEN + ); + } +} + +/** + * @this {Internals} + * + * @returns {void} once sensitive data removed + */ +async function cleanupSensitiveData() { + await this.manager.cleanupConfig(this.poller); +} + +/** + * @this {Internals} + * + * @returns {boolean} true once stats collected + */ +async function collectStats() { + this.stats = { + context: {}, + stats: {} + }; + + const config = await getConfig.call(this, true); + const statsCollector = await (typeof config.endpoints === 'undefined' + ? getDefaultStatsCollector + : getCustomStatsCollector).call(this, config); + + if (statsCollector === null) { + this.logger.debug('No stats to collect!'); + return true; + } + + try { + this.stats.stats = (await waitTillStatsCollected.call(this, statsCollector)).stats; + } catch (error) { + error.message = `Poller.collectStats: unable to collect stats: ${error.message}`; + throw error; + } finally { + statsCollector.loader.eraseCache(); + } + + return true; +} + +/** + * @this {Internals} + * + * @property {boolean} [decrypt = false] + * + * @returns {SystemPollerComponent} once the config + */ +async function getConfig(decrypt = false) { + return this.manager.getConfig(this.poller, decrypt); +} + +/** + * @this {Internals} + * + * @property {SystemPollerComponent} config + * + * @returns {Collector | null} instance to collect stats from custom endpoints or null when no stats to collect + */ +async function getCustomStatsCollector(config) { + this.state.isCustom = true; + + const customProps = properties.custom(config.endpoints, config.dataOpts.actions); + if (Object.keys(customProps.properties).length === 0) { + return null; + } + + const loader = await getLoader.call(this, config); + loader.setEndpoints(customProps.endpoints); + + return new Collector( + loader, + customProps.properties, + { + isCustom: true, + logger: this.logger.getChild('custom'), + workers: config.workers + } + ); +} + +/** + * @this {Internals} + * + * @property {SystemPollerComponent} config + * + * @returns {Collector | null} instance to collect stats from default endpoints or null when no stats to collect + */ +async function getDefaultStatsCollector(config) { + this.state.isCustom = false; + + const loader = await getLoader.call(this, config); + const contextProps = properties.context(); + + const contextCollector = new Collector( + loader, + contextProps.properties, + { + isCustom: false, + logger: this.logger.getChild('context'), + workers: config.workers + } + ); + // should be fast, no need to listen for poller termination + loader.setEndpoints(contextProps.endpoints); + + try { + this.stats.context = (await waitTillStatsCollected.call(this, contextCollector)).stats; + } catch (error) { + error.message = `Poller.collectContext: unable to collect device context data: ${error.message}`; + throw error; + } + + const defaultProps = properties.default({ + contextData: this.stats.context, + dataActions: config.dataOpts.actions, + includeTMStats: !config.dataOpts.noTMStats, + tags: config.dataOpts.tags + }); + if (Object.keys(defaultProps.properties).length === 0) { + return null; + } + + loader.setEndpoints(defaultProps.endpoints); + return new Collector( + loader, + defaultProps.properties, + { + isCustom: false, + logger: this.logger.getChild('stats'), + workers: config.workers + } + ); +} + +/** @returns {number} number of seconds spent to complete current polling cycle */ +function getDuration() { + if (this.state.endTimestamp && this.state.startTimestamp) { + return Math.floor((this.state.endTimestamp - this.state.startTimestamp) / 1000); + } + return 0; +} + +/** + * @this {Internals} + * + * @returns {PollerInfo} poller's current info data + */ +function getInfo() { + let nextFireDate; + let prevFireDate; + let timeTill; + + if (this.state && this.state.execDate) { + nextFireDate = (new Date(this.state.execDate)).toISOString(); + timeTill = Math.floor(timeUntilNextExecution.call(this) / 1000); + } else { + nextFireDate = 'not set'; + timeTill = 'not available'; + } + if (this.state && this.state.prevExecDate) { + prevFireDate = (new Date(this.state.prevExecDate)).toISOString(); + } else { + prevFireDate = 'not set'; + } + + const ret = { + nextFireDate, + onePassOnly: this.poller.onePassOnly, + prevFireDate, + state: util.deepCopy(this.storage), + terminated: this.terminated, + timeUntilNextExecution: timeTill + }; + if (ret.state && ret.state.state) { + ret.state.state.lastKnownState = String(ret.state.state.lastKnownState); + } + return ret; +} + +/** + * @this {Internals} + * + * @returns {Loader} instance that passed auth process + */ +async function getLoader(config) { + if (typeof this.httpAgent === 'undefined' && Array.isArray(config.httpAgentOpts)) { + this.logger.debug(`Using HTTP aagent with options: ${JSON.stringify(config.httpAgentOpts)}`); + this.httpAgent = getAgent(config).agent; + } + + const loader = new Loader(config.connection.host, { + agent: this.httpAgent, + chunkSize: config.chunkSize, + connection: config.connection, + credentials: config.credentials, + logger: this.logger, + workers: config.workers + }); + await loader.auth(); + return loader; +} + +/** + * @this {Internals} + * + * @returns {void} once polling cycle state initialized + */ +function initPollingState(prevExecDate = null) { + this.storage.stats.cycles += 1; + this.storage.state = { + cycleNo: this.storage.stats.cycles, + endTimestamp: null, + execDate: null, + isCustom: null, + lastKnownState: STATES.SCHEDULE, + prevExecDate, + startTimestamp: null, + stats: null + }; + this.state = this.storage.state; +} + +/** + * @this {Internals} + * + * @returns {void} once storage data initialized and ready + */ +function initStorageData() { + this.storage = { + history: [], + state: null, + stats: { + cycles: 0, + cyclesCompleted: 0, + statsCollected: 0, + statsProcessed: 0 + } + }; +} + +/** + * @this {Internals} + * + * @returns {PollingHistory} history record + */ +function makeHistoryRecord() { + return { + cycleNo: this.state.cycleNo, + end: this.state.endTimestamp, + endISO: (new Date(this.state.endTimestamp)).toISOString(), + errorMsg: this.state.errorMsg, + isCustom: this.state.isCustom, + schedule: this.state.execDate, + scheduleISO: (new Date(this.state.execDate)).toISOString(), + state: String(this.state.lastKnownState), + start: this.state.startTimestamp, + startISO: (new Date(this.state.startTimestamp)).toISOString() + }; +} + +/** + * @this {Poller} + * + * @param {ManagerProxy} manager + * @param {OnReportCallback} callback + * + * @returns {Internals} + */ +function makeInternals(manager, callback) { + const internals = {}; + const stopPromise = withResolvers(); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(internals, { + callback: { + value: callback + }, + logger: { + value: this.logger + }, + manager: { + value: manager + }, + poller: { + value: this + }, + stopPromise: { + value: stopPromise.promise + }, + terminatedCb: { + value: stopPromise.resolve + } + }); + + Object.assign(internals, { + httpAgent: undefined, + sleepPromise: null, + state: null, + stats: null, + storage: null, + terminated: false + }); + + return internals; +} + +/** + * @this {Internals} + * + * @returns {void} once polling state removed + */ +function removePollingState() { + this.state = null; + this.storage.state = null; +} + +/** + * @this {Internals} + * + * + * @returns {boolean} true once scheduled + */ +async function scheduleNextExecution() { + if (this.poller.onePassOnly === true) { + this.state.pollingInterval = 0; + } else { + const config = await getConfig.call(this); + this.state.pollingInterval = config.interval; + } + + const prevExecDate = this.state.prevExecDate ? new Date(this.state.prevExecDate) : new Date(); + this.state.execDate = (new Date(prevExecDate.getTime() + this.state.pollingInterval * 1000)).getTime(); + + this.logger.debug(`Next polling cycle starts on ${(new Date(this.state.execDate)).toISOString()} (in ${Math.floor(timeUntilNextExecution.call(this) / 1000)} s.)`); + + return true; +} + +/** + * @this {Internals} + * + * @property {CollectionResults} stats + * + * @returns {void} once report send to the manager + */ +function sendReport(stats) { + this.callback(null, this.poller, { + stats: stats.stats, + metadata: { + cycleStart: this.state.startTimestamp, + cycleEnd: this.state.endTimestamp, + deviceContext: stats.context, + isCustom: this.state.isCustom, + pollingInterval: this.state.pollingInterval + } + }); +} + +/** + * @this {Internals} + * + * @param {number} sleepTime - number of ms. to sleep + * + * @returns {boolean} true when the sleep routine succeed and was not interrupted + */ +async function sleep(sleepTime) { + if (sleepTime > constants.SYSTEM_POLLER.SECRETS_TIMEOUT) { + await cleanupSensitiveData.call(this); + } + if (this.terminated) { + return false; + } + let success = true; + if (sleepTime <= 0) { + return success; + } + + assert.safeNumber(sleepTime, 'sleepTime'); + this.sleepPromise = util.sleep(sleepTime); + + try { + await this.sleepPromise; + } catch (sleepError) { + success = false; + this.logger.debug(`Sleep routine interrupted: ${sleepError.message}`); + } finally { + this.sleepPromise = null; + } + + return success; +} + +/** + * @this {Internals} + * + * @returns {nunmber} number of milliseconds left till next scheduled execution + */ +function timeUntilNextExecution() { + return this.state.execDate - Date.now(); +} + +/** + * @this {Internals} + * + * @returns {boolean} true when the waiting routine succeed and was not interrupted + */ +async function waitTillExecDate() { + let success = true; + + while (success) { + const sleepTime = Math.min( + timeUntilNextExecution.call(this), + constants.SYSTEM_POLLER.SLEEP_INTERVAL + ); + if (sleepTime <= 0) { + break; + } + success = await sleep.call(this, sleepTime); + } + return !this.terminated && success; +} + +/** + * @this {Internals} + * + * @param {Collector} collector - data collector + * + * @returns {CollectedStats} collected data + */ +async function waitTillStatsCollected(collector) { + const results = promiseUtils.getValues(await promiseUtils.allSettled([ + collector.collect(), + (async () => { + while (collector.isActive()) { + await sleep.call(this, 100); + if (this.terminated) { + break; + } + } + await collector.stop(); + if (this.terminated) { + const msg = 'Stats collection routine terminated!'; + this.logger.debug(msg); + throw new Error(msg); + } + })() + ]))[0]; + if (results.errors.length > 0) { + throw results.errors[0]; + } + return results; +} + +module.exports = Poller; + +/** + * @typedef {object} CollectionResults + * @property {object} context - collected context data + * @property {object} stats - collected stats + */ +/** + * @typedef {String} FSMState + * @property {boolean} [allowTermination] - allow the state be terminated + * @property {string} next - next state on success + * @property {function(): RetryOptions} [onFailure] - function to run on failure + * @property {function(): boolean} onRun - function to run, should return true when succeed + * @property {function} [onSuccess] - function to run on success + */ +/** + * @typedef {object} Internals + * @property {OnReportCallback} callback - callback to call once report collected or fatal error caught + * @property {object} [httpAgent] - HTTP Agent instance + * @property {Logger} logger - logger + * @property {ManagerProxy} manager - proxy manager + * @property {Poller} poller - Poller instance + * @property {null | Promise} sleepPromise - sleep promise object or null + * @property {PollingState} state - current polling state, ref. to `StorageState.state` + * @property {CollectionResults} stats - stats for current polling cycle + * @property {Promise} stopPromise - stop promise, resolved once main polling loop stopped + * @property {StorageState} storage - storage data + * @property {boolean} terminated - true if instance was terminated + * @property {function} terminatedCb - function to call once loop terminated (upon request) + */ +/** + * @callback OnReportCallback + * @param {Error | null} error - fatal error if caught + * @param {Poller} poller - poller + * @param {StatsReport | null} report - collected report + */ +/** + * @typedef {object} PollerInfo + * @property {string} nextFireDate + * @property {boolean} onePassOnly + * @property {string} prevFireDate + * @property {StorageState} state + * @property {boolean} terminated + * @property {number} timeUntilNextExecution + */ +/** + * @typedef {object} PollingHistory + * @property {number} cycleNo - cycle number + * @property {number} end - end date timestamp + * @property {string} endISO - end date in ISO format + * @property {string} errorMsg - when state is FAILED + * @property {boolean} isCustom - `true` when custom endpoints were used to collect stats + * @property {number} schedule - origin exec date + * @property {number} scheduleISO - origin exec date in ISO format + * @property {string} state - state + * @property {number} start - start date timestamp + * @property {string} startISO - start date in ISO format + */ +/** + * @typedef {object} PollingState + * @property {number} cycleNo - iteration number + * @property {number} endTimestamp - timestamp of when polling cycle finished + * @property {string} errorMsg - error message if poll cycle failed + * @property {number} execDate - execution date + * @property {boolean} isCustom - `true` when custom endpoints were used to collect stats + * @property {string} lastKnownState - state set before data was saved + * @property {number} pollingInterval - polling interval + * @property {number} prevExecDate - previous execution date + * @property {number} startTimestamp - timestamp of when polling cycle started + * @property {CollectionResults} stats - collected starts + * @property {boolean} succeed - whether or not poll cycle completed successfully + */ +/** + * @typedef {object} PollingStats + * @property {number} cycles - number of polling cycles + * @property {number} cyclesCompleted - number of polling cycles + * @property {number} statsCollected - number of cycles when COLLECT succeed + * @property {number} statsProcessed - number of cycles when SEND_REPORT succeed + */ +/** + * @typedef {object} StatsReport + * @property {object} metadata - metadata + * @property {number} metadata.cycleStart - polling cycle start timestamp + * @property {number} metadata.cycleEnd - polling cycle end timestamp + * @property {object} metadata.deviceContext - device's context stats/data + * @property {boolean} metadata.isCustom - data produced by fetching data from custom endpoints + * @property {number} metadata.pollingInterval - polling interval + */ +/** + * @typedef {object} StorageState + * @property {PollingHistory[]} history - last 20 polling cycles history + * @property {PollingState} state - data related to current polling cycle + * @property {PollingStats} stats - System Poller stats + */ diff --git a/src/lib/systemPoller/properties.js b/src/lib/systemPoller/properties.js new file mode 100644 index 00000000..dae0a3b7 --- /dev/null +++ b/src/lib/systemPoller/properties.js @@ -0,0 +1,289 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('../utils/assert'); +const defaultPropertiesConfig = require('../properties.json'); +const defaultEndpointsConfig = require('../paths.json'); +const miscUtils = require('../utils/misc'); +const utils = require('./utils'); + +/** + * @module systemPoller/properties + */ + +const customEndpointNormalization = [ + { + renameKeys: { + patterns: { + '~': { + replaceCharacter: '/', + exactMatch: false + } + } + } + }, + { + filterKeys: { + exclude: ['kind', 'selfLink'] + } + } +]; +const defaultTags = { name: { pattern: '(.*)', group: 1 } }; + +/** + * Pre-processes Telemetry Context Endpoints and Stats + * + * @returns {{ endpoints: [], properties: {} }} preprocessed Telemetry Context Endpoints + */ +function contextProperties() { + const properties = miscUtils.deepCopy(defaultPropertiesConfig.context); + + Object.entries(properties).forEach(([propertyKey, property]) => { + property.normalization = makeNormalizationOptions(propertyKey, property, false, {}); + }); + + return { + endpoints: defaultEndpointsConfig.endpoints, + properties + }; +} + +/** + * Pre-processes custom Telemetry_Endpoints. + * Pre-processing includes: + * - Converts SNMP custom endpoint objects to properties + * - Converts custom endpoint into `property` object + * - Filter endpoints based on `dataActions` configuration + * + * @param {object} endpoints - Telemetry Endpoints object + * @param {object[]} dataActions - data actions + * + * @returns {{ endpoints: [], properties: {} }} preprocessed Telemetry Custom Endpoints + */ +function customEndpoints(endpoints, dataActions) { + assert.oneOfAssertions( + () => assert.object(endpoints, 'endpoints'), + () => assert.emptyObject(endpoints, 'endpoints') + ); + assert.array(dataActions, 'dataActions'); + + const properties = {}; + endpoints = Object.entries(miscUtils.deepCopy(endpoints)) + .map(([name, endpoint]) => { + assert.bigip.customEndpoint(endpoint, `endpoint[${name}]`); + + const propertyKey = endpoint.name; + endpoint.name = name; // override name to make endpoint be available for lookup + + if (endpoint.protocol === 'snmp') { + endpoint.body = { + command: 'run', + utilCmdArgs: `-c "snmpwalk -L n -O ${endpoint.numericalEnums ? 'e' : ''}QUs -c public localhost ${endpoint.path}"` + }; + endpoint.path = '/mgmt/tm/util/bash'; + } + + properties[propertyKey] = makePropertyFromCustomEndpoint(name, endpoint); + properties[propertyKey].normalization = makeNormalizationOptions( + propertyKey, + properties[propertyKey], + true + ); + return endpoint; + }); + + return { + endpoints, + properties: utils.filterStats(properties, dataActions) + }; +} + +/** + * Pre-processes Telemetry Default Endpoints and Stats + * Pre-processing includes: + * - Filter endpoints based on `dataActions` configuration + * + * @returns {{ endpoints: [], properties: {} }} preprocessed Telemetry Default Endpoints + */ +function defaultProperties({ + contextData = undefined, + dataActions = undefined, + includeTMStats = true, + tags = undefined +}) { + assert.oneOfAssertions( + () => assert.object(contextData, 'contextData'), + () => assert.emptyObject(contextData, 'contextData') + ); + assert.array(dataActions, 'dataActions'); + assert.boolean(includeTMStats, 'includeTMStats'); + assert.oneOfAssertions( + () => assert.not.defined(tags, 'tags'), + () => assert.object(tags, 'tags'), + () => assert.emptyObject(tags, 'tags') + ); + + // filter and copy reduced amount of data first + const properties = miscUtils.deepCopy( + utils.filterStats(defaultPropertiesConfig.stats, dataActions, !includeTMStats) + ); + Object.entries(properties).forEach(([propertyKey, property]) => { + property = utils.renderProperty(contextData, property); + + if (property.disabled) { + delete properties[propertyKey]; + return; + } + + property.normalization = makeNormalizationOptions(propertyKey, property, false, tags); + }); + + return { + endpoints: defaultEndpointsConfig.endpoints, + properties + }; +} + +/** + * Create/update normalization options + * + * @param {string} propertyKey + * @param {object} property + * @param {boolean} isCustom + * @param {object} tags + * + * @returns {object} + */ +function makeNormalizationOptions(propertyKey, property, isCustom, tags) { + const result = { + propertyKey, + normalization: property.normalization + }; + const childKey = utils.splitKey(property.key).childKey; + if (childKey) { + result.key = childKey; + } + + if (isCustom) { + return result; + } + + const options = {}; + if (property.normalization) { + const addKeysByTagIsObject = property.normalization.find((n) => n.addKeysByTag && typeof n.addKeysByTag === 'object'); + + const filterKeysIndex = property.normalization.findIndex((i) => i.filterKeys); + if (filterKeysIndex > -1) { + property.normalization[filterKeysIndex] = { + filterKeys: [ + property.normalization[filterKeysIndex].filterKeys, + defaultPropertiesConfig.global.filterKeys + ] + }; + } else { + options.filterKeys = [defaultPropertiesConfig.global.filterKeys]; + } + + const renameKeysIndex = property.normalization.findIndex((i) => i.renameKeys); + if (renameKeysIndex > -1) { + property.normalization[renameKeysIndex] = { + renameKeys: [ + property.normalization[renameKeysIndex].renameKeys, + defaultPropertiesConfig.global.renameKeys + ] + }; + } else { + options.renameKeys = [defaultPropertiesConfig.global.renameKeys]; + } + + const addKeysByTagIndex = property.normalization.findIndex((i) => i.addKeysByTag); + if (addKeysByTagIndex > -1) { + property.normalization[addKeysByTagIndex] = { + addKeysByTag: { + tags: Object.assign({}, defaultTags, tags), + definitions: defaultPropertiesConfig.definitions, + opts: addKeysByTagIsObject ? property.normalization[addKeysByTagIndex] + .addKeysByTag : defaultPropertiesConfig.global.addKeysByTag + } + }; + } else { + property.normalization.push({ + addKeysByTag: { + tags: defaultTags, + definitions: defaultPropertiesConfig.definitions, + opts: defaultPropertiesConfig.global.addKeysByTag + } + }); + } + + property.normalization.push({ formatTimestamps: defaultPropertiesConfig.global.formatTimestamps.keys }); + } else { + options.filterKeys = [defaultPropertiesConfig.global.filterKeys]; + options.renameKeys = [defaultPropertiesConfig.global.renameKeys]; + options.formatTimestamps = defaultPropertiesConfig.global.formatTimestamps.keys; + options.addKeysByTag = { + tags: defaultTags, + definitions: defaultPropertiesConfig.definitions, + opts: defaultPropertiesConfig.global.addKeysByTag + }; + } + return Object.assign(options, result); +} + +/** + * Converts a Telemetry_Endpoint to a standard property. + * Only BIG-IP paths currently supported, + * For e.g. /mgmt/tm/subPath?$select=prop1,prop2 + * (Note that we don't guarantee behavior for all types of query params) + * + * @param {string} endpointKey - endpoint key + * @param {object} endpoint - object to convert + * + * @returns {{key: string, isCustom: boolean, normalization: Array}} Converted property + */ +function makePropertyFromCustomEndpoint(endpointKey, endpoint) { + const normalization = miscUtils.deepCopy(customEndpointNormalization); + if (endpoint.protocol === 'snmp') { + normalization.push({ + runFunctions: [{ name: 'restructureSNMPEndpoint', args: {} }] + }); + } + const statsIndex = endpoint.path.indexOf('/stats'); + const bigipBasePath = 'mgmt/tm/'; + const baseIndex = endpoint.path.indexOf(bigipBasePath); + + if (statsIndex > -1 && baseIndex > -1) { + const mgmtTmIndex = endpoint.path.indexOf(bigipBasePath) + bigipBasePath.length; + const renameKeys = { patterns: {} }; + const pathMatch = endpoint.path.substring(mgmtTmIndex, statsIndex); + + // eslint-disable-next-line no-useless-escape + renameKeys.patterns[pathMatch] = { pattern: `${pathMatch}\/(.*)`, group: 1 }; + normalization.push({ renameKeys }); + } + return { + key: endpointKey, + normalization + }; +} + +module.exports = { + context: contextProperties, + custom: customEndpoints, + default: defaultProperties +}; diff --git a/src/lib/systemPoller/utils.js b/src/lib/systemPoller/utils.js new file mode 100644 index 00000000..991511e7 --- /dev/null +++ b/src/lib/systemPoller/utils.js @@ -0,0 +1,324 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const mustache = require('mustache'); + +const constants = require('../constants'); +const dataUtil = require('../utils/data'); +const util = require('../utils/misc'); + +/** + * @module systemPoller/utils + */ + +/** + * Comparison functions + * + * NOTE: + * Functions below are BIG-IP specific. + */ +const CONDITIONAL_FUNCS = { + /** + * Compare device versions + * + * @param {object} contextData - context data + * @param {object} contextData.deviceVersion - device's version to compare + * @param {string} versionToCompare - version to compare against + * + * @returns {boolean} true when device's version is greater or equal + */ + deviceVersionGreaterOrEqual(contextData, versionToCompare) { + const deviceVersion = contextData.deviceVersion; + if (deviceVersion === undefined) { + throw new Error('deviceVersionGreaterOrEqual: context has no property \'deviceVersion\''); + } + return util.compareVersionStrings(deviceVersion, '>=', versionToCompare); + }, + + /** + * Compare provisioned modules + * + * @param {object} contextData - context data + * @param {object} contextData.provisioning - provision state of modules to compare + * @param {string} moduleToCompare - module to compare against + * + * @returns {boolean} true when device's module is provisioned + */ + isModuleProvisioned(contextData, moduleToCompare) { + const provisioning = contextData.provisioning; + if (provisioning === undefined) { + throw new Error('isModuleProvisioned: context has no property \'provisioning\''); + } + return ((provisioning[moduleToCompare] || {}).level || 'none') !== 'none'; + }, + + /** + * Compares Bash state between device and expected Bash state + * + * @param {object} contextData - context data + * @param {Boolean} contextData.bashDisabled - whether or not Bash has been disabled + * @param {Boolean} stateToCompare - state of Bash to check against + * + * @returns {boolean} true when device's bash state is the same as the desired state + */ + isBashDisabled(contextData, stateToCompare) { + const bashDisabled = contextData.bashDisabled; + if (bashDisabled === undefined) { + throw new Error('isBashDisabled: context has no property \'bashDisabled\''); + } + return bashDisabled === stateToCompare; + } +}; + +/** + * Compute stats's path + * + * @param {object} stats - stats structure + * @param {string} statKey - stat's key + * + * @returns {string[]} - path to stat + */ +function computeStatPath(stats, statKey) { + const path = [statKey]; + const stat = stats[statKey]; + if (stat.structure && stat.structure.parentKey) { + path.push(stat.structure.parentKey); + } + return path.reverse(); +} + +/** + * Applies all filters from declaration, to the set of System Stat properties that will be collected. + * Processing of filters occurs sequentially, from top-to-bottom (of the declaration). + * All filtering of properties / fields by value (ex: VirtualServers matching name of 'test*') must be handled after + * stats are collected, and will not be filtered by _filterStats(). + * + * @param {*} stats + * @param {*} actions + * @param {boolean} [ignoreTMStats = true] - ignore TMStats data + * + * @returns {*} filtered stats + */ +function filterStats(stats, actions, ignoreTMStats = true) { + /** + * This function for optimization purpose only - it is not actual data exclusion, + * it only disables endpoints we are not going to use at all. + * + * From the user's point of view this process should be explicit - the user should still think + * that TS fetches all data. + * + * Ideally this function should be ran just once per System Poller's config update but due nature of + * System Poller and the fact that it runs every 60 secs or more we can compute on demand every time + * to avoid memory usage. + */ + const FLAGS = { + UNTOUCHED: 0, + PRESERVE: 1 + }; + /** + * Reasons to create tree of stats that mimics actual TS output: + * - much easier deal with regular expressions (at least can avoid regex comparisons) + * - all location pointers are object of objects + */ + const statsSkeleton = {}; + const nestedKey = 'nested'; + + Object.keys(stats).forEach((statKey) => { + const stat = stats[statKey]; + if (ignoreTMStats && (statKey === 'tmstats' || (stat.structure && stat.structure.parentKey === 'tmstats'))) { + // definetly data from properties.json - ignore it + return; + } + + if (!stat.structure) { + statsSkeleton[statKey] = { flag: FLAGS.UNTOUCHED }; + } else if (stat.structure.parentKey) { + if (!statsSkeleton[stat.structure.parentKey]) { + statsSkeleton[stat.structure.parentKey] = { flag: FLAGS.UNTOUCHED }; + } + statsSkeleton[stat.structure.parentKey][nestedKey] = statsSkeleton[stat.structure.parentKey][nestedKey] + || {}; + statsSkeleton[stat.structure.parentKey][nestedKey][statKey] = { flag: FLAGS.UNTOUCHED }; + } + }); + + actions.forEach((actionCtx) => { + if (!actionCtx.enable) { + return; + } + if (actionCtx.ifAllMatch || actionCtx.ifAnyMatch) { + // if ifAllMatch or ifAnyMatch points to nonexisting data - VS name, tag or what ever else + // we have to mark all existing paths with PRESERVE flag + dataUtil.searchAnyMatches(statsSkeleton, actionCtx.ifAllMatch || actionCtx.ifAnyMatch, (key, item) => { + item.flag = FLAGS.PRESERVE; + return nestedKey; + }); + } + // if includeData/excludeData paired with ifAllMatch or ifAnyMatch then we can simply ignore it + // because we can't include/exclude data without conditional check + if (actionCtx.excludeData && !(actionCtx.ifAllMatch || actionCtx.ifAnyMatch)) { + dataUtil.removeStrictMatches(statsSkeleton, actionCtx.locations, (key, item, getNestedKey) => { + if (getNestedKey) { + return nestedKey; + } + return item.flag !== FLAGS.PRESERVE; + }); + } + if (actionCtx.includeData && !(actionCtx.ifAllMatch || actionCtx.ifAnyMatch)) { + // strict is false - it is okay to have partial matches because we can't be sure + // for 100% that such data was not added by previous action + dataUtil.preserveStrictMatches(statsSkeleton, actionCtx.locations, false, (key, item, getNestedKey) => { + if (getNestedKey) { + return nestedKey; + } + return item.flag !== FLAGS.PRESERVE; + }); + } + }); + + const activeStats = {}; + Object.keys(stats).forEach((statKey) => { + let skeleton = statsSkeleton; + // path to stat should exists otherwise we can delete it + const exists = computeStatPath(stats, statKey).every((key) => { + skeleton = skeleton[key]; + if (skeleton && skeleton[nestedKey]) { + skeleton = skeleton[nestedKey]; + } + return skeleton; + }); + if (exists) { + activeStats[statKey] = stats[statKey]; + } + }); + + return activeStats; +} + +/** + * Evaluate conditional block + * + * @param {object} contextData - contextData object + * @param {object} conditionalBlock - block to evaluate, where object's key - conditional operator + * object's value - params for that operator + * + * @returns {boolean} conditional result + */ +function resolveConditional(contextData, conditionalBlock) { + return Object.keys(conditionalBlock).every((key) => { + const func = CONDITIONAL_FUNCS[key]; + if (func === undefined) { + throw new Error(`Unknown property '${key}' in conditional block`); + } + return func(contextData, conditionalBlock[key]); + }); +} + +/** + * Render property using mustache template system. + * + * Note: mutates 'property' + * + * @param {object} contextData - context object + * @param {object} property - property object + * + * @returns {void} when finished + */ +function renderTemplate(contextData, property) { + util.traverseJSON(property, (parent, key) => { + const val = parent[key]; + if (typeof val === 'string') { + const startIdx = val.indexOf('{{'); + if (startIdx !== -1 && val.indexOf('}}', startIdx) > startIdx) { + parent[key] = mustache.render(val, contextData); + } + } + }); +} + +/** + * Render property based on template and conditionals + * + * Note: mutates 'property' + * + * @param {object} contextData - contextData object + * @param {object} property - property object + * + * @returns {object} rendered property + */ +function renderProperty(contextData, property) { + renderTemplate(contextData, property); + preprocessProperty(contextData, property); + return property; +} + +/** + * Split key + * + * @param {string} key - key to split + * + * @returns {object} Return data formatted like { rootKey: 'key, childKey: 'key' } + */ +function splitKey(key) { + const idx = key.indexOf(constants.STATS_KEY_SEP); + const ret = { rootKey: key.slice(0, idx === -1 ? key.length : idx) }; + if (idx !== -1) { + ret.childKey = key.slice(idx + constants.STATS_KEY_SEP.length, key.length); + } + return ret; +} + +/** + * Property pre-processing to resolve conditionals + * + * Note: mutates 'property' + * + * @param {object} contextData - context object + * @param {object} property - property object + * + * @returns {void} when finished + */ +function preprocessProperty(contextData, property) { + // put 'property' inside of object to be able to + // process 'if' on the top level + util.traverseJSON({ property }, (parent, key) => { + const val = parent[key]; + // run while 'if' block exist on current level + while (typeof val === 'object' + && !Array.isArray(val) + && val !== null + && typeof val.if !== 'undefined' + ) { + const block = resolveConditional(contextData, val.if) ? val.then : val.else; + // delete blocks at first to avoid collisions with nested blocks + delete val.if; + delete val.then; + delete val.else; + + if (typeof block === 'object' && !Array.isArray(block)) { + Object.assign(val, block); + } + } + }); +} + +module.exports = { + filterStats, + renderProperty, + splitKey +}; diff --git a/src/lib/systemStats.js b/src/lib/systemStats.js deleted file mode 100644 index 70bc00a0..00000000 --- a/src/lib/systemStats.js +++ /dev/null @@ -1,597 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const util = require('./utils/misc'); -const normalize = require('./normalize'); -const defaultProperties = require('./properties.json'); -const defaultPaths = require('./paths.json'); -const logger = require('./logger'); -const EndpointLoader = require('./endpointLoader'); -const dataUtil = require('./utils/data'); -const promiseUtil = require('./utils/promise'); -const systemStatsUtil = require('./utils/systemStats'); - -/** @module systemStats */ - -const customEndpointNormalization = [ - { - renameKeys: { - patterns: { - '~': { - replaceCharacter: '/', - exactMatch: false - } - } - } - }, - { - filterKeys: { - exclude: ['kind', 'selfLink'] - } - } -]; - -/** - * System Stats Class - * - * @param {Object} config - config object - * @param {Object} [config.connection] - connection info - * @param {String} [config.connection.host] - host to connect to - * @param {Integer} [config.connection.port] - port to use - * @param {String} [config.connection.protocol] - protocol to use to connect - * @param {Boolean} [config.connection.allowSelfSignedCert] - false - requires SSL certificates be valid, - * true - allows self-signed certs - * @param {String} [config.credentials.username] - username for host - * @param {String} [config.credentials.passphrase] - password for host - * @param {Object} [config.dataOpts] - data options - * @param {Object} [config.dataOpts.tags] - tags to add to the data (each key) - * @param {Object} [config.dataOpts.actions] - actions to apply to the data (each key) - * @param {Boolean} [config.dataOpts.noTMStats] - true if don't need to fetch TMSTAT data - * @param {Object} [config.endpoints] - endpoints to use to fetch data - * @param {String} [config.name] - name - */ -function SystemStats(config) { - config = util.assignDefaults(config, { - name: 'UnknownPoller', - connection: {}, - credentials: {}, - dataOpts: { - tags: {}, - noTMStats: false, - actions: [] - } - }); - - this.logger = logger.getChild(`${config.name}`); - this.noTMStats = config.dataOpts.noTMStats; - this.tags = config.dataOpts.tags; - this.actions = config.dataOpts.actions; - this.collectedData = {}; - - this.loader = new EndpointLoader( - config.connection.host, - { - credentials: util.deepCopy(config.credentials), - connection: util.deepCopy(config.connection), - logger: this.logger - } - ); - - const paths = config.paths || defaultPaths; - const properties = config.properties || defaultProperties; - this.global = properties.global; - - if (typeof config.endpoints === 'undefined') { - this.stats = properties.stats; - this.statsToSkip = null; - this.definitions = properties.definitions; - this.endpoints = paths.endpoints; - this.contextProps = properties.context; - this.contextData = {}; - } else { - this.endpoints = this._preprocessEndpoints(config.endpoints); - this.isCustom = true; - } -} - -/** - * Preprocess custom Telemetry_Endpoints. Preprocessing includes: - * - Converts SNMP custom endpoint objects to properties - * - * @param {Object} endpoints - Telemetry Endpoints object - * - * @returns {Object} preprocessed Telemetry Endpoints - */ -SystemStats.prototype._preprocessEndpoints = function (endpoints) { - // Deep copy so endpoint object is not modified in the saved configuration - endpoints = util.deepCopy(endpoints); - Object.keys(endpoints).forEach((endpoint) => { - if (endpoints[endpoint].protocol === 'snmp') { - const additionalOptions = endpoints[endpoint].numericalEnums ? 'e' : ''; - - endpoints[endpoint].body = { - command: 'run', - utilCmdArgs: `-c "snmpwalk -L n -O ${additionalOptions}QUs -c public localhost ${endpoints[endpoint].path}"` - }; - endpoints[endpoint].path = '/mgmt/tm/util/bash'; - } - }); - return endpoints; -}; - -SystemStats.prototype._getNormalizationOpts = function (property) { - if (property.isCustom) { - return {}; - } - - const options = {}; - const defaultTags = { name: { pattern: '(.*)', group: 1 } }; - const addKeysByTagIsObject = property.normalization - && property.normalization.find((n) => n.addKeysByTag && typeof n.addKeysByTag === 'object'); - - if (property.normalization) { - const filterKeysIndex = property.normalization.findIndex((i) => i.filterKeys); - if (filterKeysIndex > -1) { - property.normalization[filterKeysIndex] = { - filterKeys: [ - property.normalization[filterKeysIndex].filterKeys, - this.global.filterKeys - ] - }; - } else { - options.filterKeys = [this.global.filterKeys]; - } - - const renameKeysIndex = property.normalization.findIndex((i) => i.renameKeys); - if (renameKeysIndex > -1) { - property.normalization[renameKeysIndex] = { - renameKeys: [ - property.normalization[renameKeysIndex].renameKeys, - this.global.renameKeys - ] - }; - } else { - options.renameKeys = [this.global.renameKeys]; - } - - const addKeysByTagIndex = property.normalization.findIndex((i) => i.addKeysByTag); - if (addKeysByTagIndex > -1) { - property.normalization[addKeysByTagIndex] = { - addKeysByTag: { - tags: Object.assign(defaultTags, this.tags), - definitions: this.definitions, - opts: addKeysByTagIsObject ? property.normalization[addKeysByTagIndex] - .addKeysByTag : this.global.addKeysByTag - } - }; - } else { - property.normalization.push({ - addKeysByTag: { - tags: defaultTags, - definitions: this.definitions, - opts: this.global.addKeysByTag - } - }); - } - - property.normalization.push({ formatTimestamps: this.global.formatTimestamps.keys }); - } else { - options.filterKeys = [this.global.filterKeys]; - options.renameKeys = [this.global.renameKeys]; - options.formatTimestamps = this.global.formatTimestamps.keys; - options.addKeysByTag = { - tags: defaultTags, - definitions: this.definitions, - opts: this.global.addKeysByTag - }; - } - return options; -}; - -/** - * Process loaded data - * - * @param {Object} property - property object - * @param {Object} data - data object - * @param {string} key - property key associated with data - * - * @returns {Object} normalized data (if needed) - */ -SystemStats.prototype._processData = function (property, data, key) { - // standard options for normalize, these are driven primarily by the properties file - const options = Object.assign(this._getNormalizationOpts(property), { - key: systemStatsUtil.splitKey(property.key).childKey, - propertyKey: key, - normalization: property.normalization - }); - return property.normalize === false ? data : normalize.data(data, options); -}; -/** - * Load data for property - * - * @param {Object} property - property object - * @param {String} [property.key] - key to identify endpoint to load data from - * @param {String} [property.keyArgs] - arguments to pass to the endpoint - * @returns {Object} Promise resolved with fetched data object - */ -SystemStats.prototype._loadData = function (property) { - const endpoint = systemStatsUtil.splitKey(property.key).rootKey; - - return this.loader.loadEndpoint(endpoint, property.keyArgs) - .then((data) => { - data = data.data; - if (data && typeof data === 'object' && typeof data.items === 'undefined' - && Object.keys(data).length === 2 && data.kind.endsWith('state')) { - data.items = []; - } - return Promise.resolve(data); - }) - .catch((err) => { - this.logger.error(`Error: SystemStats._loadData: ${endpoint} (${property.keyArgs}): ${err}`); - return Promise.reject(err); - }); -}; -/** - * Return parent object to store data - * - * @param {Object} property - property - * - * @returns {Object} to store data - */ -SystemStats.prototype._getParentObjectForStat = function (property) { - let parentObj = this.collectedData; - if (property.structure && property.structure.parentKey) { - this.collectedData[property.structure.parentKey] = this.collectedData[property.structure.parentKey] || {}; - parentObj = this.collectedData[property.structure.parentKey]; - } - return parentObj; -}; -/** - * Process property - * - * @param {String} key - key to store collected data - * @param {Object} property - property object - * - * @returns {Object} Promise resolved when data was successfully colleted - */ -SystemStats.prototype._processProperty = function (key, property) { - property = systemStatsUtil.renderProperty(this.contextData, util.deepCopy(property)); - /** - * if endpoints will have their own 'disabled' flag - * we will need to add additional check here or simply return empty value. - * An Empty value will result in 'missing key' after normalization. - */ - if (property.disabled) { - return Promise.resolve(); - } - - // skip folders - no processing required - if (property.structure && property.structure.folder === true) { - this.collectedData[key] = this.collectedData[key] || {}; - return Promise.resolve(); - } - - return this._loadData(property) - .then((data) => { - const processedData = this._processData(property, data, key); - // Only add data to collectedData that exists/is not empty - if (!(processedData === undefined || processedData.length === 0)) { - this._getParentObjectForStat(property)[key] = processedData; - } - }) - .catch((err) => { - // For custom endpoints only, add an empty object to response, to show TS tried to load the endpoint - if (property.isCustom) { - this.collectedData[key] = {}; - } - this.logger.error(`Error: SystemStats._processProperty: ${key} (${property.key}): ${err}`); - return Promise.reject(err); - }); -}; -/** - * Compute all contextual data - * - * @param {Object | Array} contextData - context object(s) to load - * - * @returns (Object) Promise resolved when contextual data were loaded - */ -SystemStats.prototype._computeContextData = function () { - if (this.contextProps) { - const promises = Object.keys(this.contextProps) - .map((key) => this._processProperty(key, this.contextProps[key])); - - return promiseUtil.allSettled(promises) - .then((results) => { - promiseUtil.getValues(results); // throws error if found it - Object.assign(this.contextData, this.collectedData); - this.collectedData = {}; - }); - } - return Promise.resolve(); -}; -/** - * Applies all filters from declaration, to the set of System Stat properties that will be collected. - * Processing of filters occurs sequentially, from top-to-bottom (of the declaration). - * All filtering of properties / fields by value (ex: VirtualServers matching name of 'test*') must be handled after - * stats are collected, and will not be filtered by _filterStats(). - * - * @returns {Object} Promise resolved when all filtering is completed - */ -SystemStats.prototype._filterStats = function () { - /** - * This function for optimization purpose only - it is not actual data exclusion, - * it only disables endpoints we are not going to use at all. - * - * From the user's point of view this process should be explicit - the user should still think - * that TS fetches all data. - * - * Ideally this function should be ran just once per System Poller's config update but due nature of - * System Poller and the fact that it runs every 60 secs or more we can compute on demand every time - * to avoid memory usage. - */ - // early return - if (this.isStatsFilterApplied) { - return; - } - const FLAGS = { - UNTOUCHED: 0, - PRESERVE: 1 - }; - /** - * Reasons to create tree of stats that mimics actual TS output: - * - much easier deal with regular expressions (at least can avoid regex comparisons) - * - all location pointers are object of objects - */ - const statsSkeleton = {}; - const nestedKey = 'nested'; - // endpoints has flat structure for now - no additional processing required - const stats = this.isCustom ? this.endpoints : this.stats; - - Object.keys(stats).forEach((statKey) => { - const stat = stats[statKey]; - if (this.noTMStats && stat.structure && stat.structure.parentKey === 'tmstats') { - return; - } - - if (!stat.structure) { - statsSkeleton[statKey] = { flag: FLAGS.UNTOUCHED }; - } else if (stat.structure.folder) { - if (!statsSkeleton[statKey]) { - statsSkeleton[statKey] = { flag: FLAGS.UNTOUCHED }; - } - } else if (stat.structure.parentKey) { - if (!statsSkeleton[stat.structure.parentKey]) { - statsSkeleton[stat.structure.parentKey] = { flag: FLAGS.UNTOUCHED }; - } - statsSkeleton[stat.structure.parentKey][nestedKey] = statsSkeleton[stat.structure.parentKey][nestedKey] - || {}; - statsSkeleton[stat.structure.parentKey][nestedKey][statKey] = { flag: FLAGS.UNTOUCHED }; - } - }); - this.actions.forEach((actionCtx) => { - if (!actionCtx.enable) { - return; - } - if (actionCtx.ifAllMatch || actionCtx.ifAnyMatch) { - // if ifAllMatch or ifAnyMatch points to nonexisting data - VS name, tag or what ever else - // we have to mark all existing paths with PRESERVE flag - dataUtil.searchAnyMatches(statsSkeleton, actionCtx.ifAllMatch || actionCtx.ifAnyMatch, (key, item) => { - item.flag = FLAGS.PRESERVE; - return nestedKey; - }); - } - // if includeData/excludeData paired with ifAllMatch or ifAnyMatch then we can simply ignore it - // because we can't include/exclude data without conditional check - if (actionCtx.excludeData && !(actionCtx.ifAllMatch || actionCtx.ifAnyMatch)) { - dataUtil.removeStrictMatches(statsSkeleton, actionCtx.locations, (key, item, getNestedKey) => { - if (getNestedKey) { - return nestedKey; - } - return item.flag !== FLAGS.PRESERVE; - }); - } - if (actionCtx.includeData && !(actionCtx.ifAllMatch || actionCtx.ifAnyMatch)) { - // strict is false - it is okay to have partial matches because we can't be sure - // for 100% that such data was not added by previous action - dataUtil.preserveStrictMatches(statsSkeleton, actionCtx.locations, false, (key, item, getNestedKey) => { - if (getNestedKey) { - return nestedKey; - } - return item.flag !== FLAGS.PRESERVE; - }); - } - }); - - const activeStats = {}; - Object.keys(stats).forEach((statKey) => { - let skeleton = statsSkeleton; - // path to stat should exists otherwise we can delete it - const exists = computeStatPath(stats, statKey).every((key) => { - skeleton = skeleton[key]; - if (skeleton && skeleton[nestedKey]) { - skeleton = skeleton[nestedKey]; - } - return skeleton; - }); - if (exists) { - activeStats[statKey] = stats[statKey]; - } - }); - - if (this.isCustom) { - this.endpoints = activeStats; - } else { - this.stats = activeStats; - } - this.isStatsFilterApplied = true; -}; - -/** - * Converts a Telemetry_Endpoint to a standard property. - * Only BIG-IP paths currently supported, - * For e.g. /mgmt/tm/subPath?$select=prop1,prop2 - * (Note that we don't guarantee behavior for all types of query params) - * - * @param {String} keyName - property key - * @param {Object} endpoint - object to convert - * - * @returns {Object} Converted property - */ - -SystemStats.prototype._convertToProperty = function (keyName, endpoint) { - const normalization = util.copy(customEndpointNormalization); - if (endpoint.protocol === 'snmp') { - normalization.push({ - runFunctions: [{ name: 'restructureSNMPEndpoint', args: {} }] - }); - } - const statsIndex = endpoint.path.indexOf('/stats'); - const bigipBasePath = 'mgmt/tm/'; - if (statsIndex > -1) { - const mgmtTmIndex = endpoint.path.indexOf(bigipBasePath) + bigipBasePath.length; - const renameKeys = { patterns: {} }; - const pathMatch = endpoint.path.substring(mgmtTmIndex, statsIndex); - - // eslint-disable-next-line no-useless-escape - renameKeys.patterns[pathMatch] = { pattern: `${pathMatch}\/(.*)`, group: 1 }; - normalization.push({ renameKeys }); - } - return { - key: keyName, - isCustom: true, - normalization - }; -}; - -/** - * Compute properties - * - * @param {Object} propertiesData - object with properties - * - * @returns {Object} Promise resolved when all properties were loaded - */ -SystemStats.prototype._computePropertiesData = function () { - return promiseUtil.allSettled( - Object - .keys(this.stats) - .map((key) => this._processProperty(key, this.stats[key])) - ) - .then((results) => promiseUtil.getValues(results)); // throws error if found it -}; - -/** - * Collect info based on object provided in paths and properties files (builtin/ defaults) - * - * @returns {Object} Promise which is resolved with a map of stats - */ -SystemStats.prototype.collectDefaultPathsProps = function () { - return this._computeContextData() - .then(() => this._computePropertiesData()) - .then(() => this.collectedData); -}; - -/** - * Collect info based on object provided in declaration (config from user input) - * Currently customEndpoints supported are only BIG-IP endpoints - * - * @returns {Object} Promise - */ -SystemStats.prototype.collectCustomEndpoints = function () { - return new Promise((resolve) => { - const endpKeys = Object.keys(this.endpoints); - - const processEndpoint = (idx) => { - if (idx >= endpKeys.length) { - return resolve(this.collectedData); - } - const endpointKey = endpKeys[idx]; - const endpoint = this.endpoints[endpointKey]; - const keyName = endpoint.name || endpointKey; - - return Promise.resolve() - .then(() => this._processProperty(keyName, this._convertToProperty(keyName, endpoint))) - .catch((err) => { - this.logger.error(`Error on attempt to load data from endpoint '${endpoint.name}[${endpoint.path}]': ${err}`); - }) - // Process the next endpoint, even if error processing current endpoint - .then(() => processEndpoint(idx + 1)); - }; - - processEndpoint(0); - }); -}; - -/** - * Collect info - * - * @returns {Object} Promise which is resolved with a map of stats - */ -SystemStats.prototype.collect = function () { - let collectedData; - let caughtErr; - this.logger.debug('Starting stats collection'); - return this.loader.auth() - .then(() => { - // apply pre-optimization to skip stats/endpoints that excluded by 'actions' - // and never be seen - reduce amount of useless requests to device - this._filterStats(); - this.loader.setEndpoints(this.endpoints); - return this.isCustom ? this.collectCustomEndpoints() : this.collectDefaultPathsProps(); - }) - .then((data) => { - collectedData = data; - }) - .catch((err) => { - caughtErr = err; - }) - .then(() => { - // erase cached data - this.loader.eraseCache(); - if (caughtErr) { - const message = caughtErr.message || `Error: SystemStats.collect: ${caughtErr}`; - this.logger.error(message); - return Promise.reject(caughtErr); - } - return Promise.resolve(collectedData); - }); -}; - -/** - * Helpers for stats filtering - */ - -/** - * Compute stats's path - * - * @param {Object} stats - stats structure - * @param {String} statKey - stat's key - * - * @returns {Array} - path to stat - */ -function computeStatPath(stats, statKey) { - const path = [statKey]; - const stat = stats[statKey]; - if (stat.structure) { - if (stat.structure.parentKey) { - path.push(stat.structure.parentKey); - } - } - return path.reverse(); -} - -module.exports = SystemStats; diff --git a/src/lib/teemReporter/index.js b/src/lib/teemReporter/index.js new file mode 100644 index 00000000..78592acc --- /dev/null +++ b/src/lib/teemReporter/index.js @@ -0,0 +1,90 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const Service = require('../utils/service'); +const TeemReporter = require('./teemReporter'); + +/** + * @module teemReporter + * + * @typedef {import('../appEvents').ApplicationEvents} ApplicationEvents + */ + +const EE_NAMESPACE = 'teem'; + +/** + * Teem Reporter Service Class + * + * @fires reported + */ +class TeemReporterService extends Service { + /** Configure and start the service */ + _onStart() { + this._reporter = new TeemReporter(); + } + + /** Stop the service */ + _onStop() { + this._reporter = null; + } + + /** @returns {Promise} resolved with true when service destroyed or if it was destroyed already */ + destroy() { + this._offConfigUpdates + && this._offConfigUpdates.off() + && (this._offConfigUpdates = null); + + return super.destroy() + .then((ret) => { + this._offMyEvents + && this._offMyEvents.off() + && (this._offMyEvents = null); + + return ret; + }); + } + + /** @param {ApplicationEvents} appEvents - application events */ + initialize(appEvents) { + this._offConfigUpdates = appEvents.on('config.prevalidated', onConfigEvent.bind(this), { objectify: true }); + this.logger.debug('Subscribed to Configuration updates.'); + + this._offMyEvents = appEvents.register(this.ee, EE_NAMESPACE, [ + { reported: 'reported' } + ]); + } +} + +/** + * @this TeemReporterService + * + * @param {object} data + * @param {object} data.declaration + * @param {object} data.metadata + * @param {object} data.transactionID + */ +function onConfigEvent(data) { + Promise.resolve() + .then(() => this._reporter.process(data.declaration)) + .catch((err) => this.logger.debugException('Unable to send analytics data', err)) + .then(() => this.ee.safeEmitAsync('reported')); +} + +module.exports = TeemReporterService; diff --git a/src/lib/teemReporter.js b/src/lib/teemReporter/teemReporter.js similarity index 92% rename from src/lib/teemReporter.js rename to src/lib/teemReporter/teemReporter.js index ced3e8c5..516845b4 100644 --- a/src/lib/teemReporter.js +++ b/src/lib/teemReporter/teemReporter.js @@ -19,13 +19,16 @@ const TeemDevice = require('@f5devcentral/f5-teem').Device; const TeemRecord = require('@f5devcentral/f5-teem').Record; -const appInfo = require('./appInfo'); -const constants = require('./constants'); -const logger = require('./logger'); +const appInfo = require('../appInfo'); +const constants = require('../constants'); const CONFIG_CLASSES = constants.CONFIG_CLASSES; -/** @module TeemReporter */ +/** + * @private + * + * @module teemReporter/TeemReporter + */ /** * TeemReporter class - Handle reporting of analytics data using F5-TEEM @@ -43,7 +46,6 @@ class TeemReporter { version: appInfo.version }; this.teemDevice = new TeemDevice(this.assetInfo); - this.logger = logger.getChild('teemReporter'); } /** @@ -66,8 +68,7 @@ class TeemReporter { .then(() => teemRecord.addProvisionedModules()) .then(() => teemRecord.addClassCount(config)) .then(() => teemRecord.addJsonObject(this.fetchExtraData(config))) - .then(() => this.teemDevice.reportRecord(teemRecord)) - .catch((err) => this.logger.debugException('Unable to send analytics data', err)); + .then(() => this.teemDevice.reportRecord(teemRecord)); } /** @@ -148,6 +149,4 @@ function searchObjectsWithClass(subDecl, cb) { }); } -module.exports = { - TeemReporter -}; +module.exports = TeemReporter; diff --git a/src/lib/tracerManager.js b/src/lib/tracerManager.js index 525fc1c3..90b476c0 100644 --- a/src/lib/tracerManager.js +++ b/src/lib/tracerManager.js @@ -48,7 +48,6 @@ function fromConfig(config) { tracer = getOrCreate(config.path, { encoding: config.encoding, inactivityTimeout: config.inactivityTimeout, - fs: config.fs, maxRecords: config.maxRecords }); } diff --git a/src/lib/utils/assert.js b/src/lib/utils/assert.js new file mode 100644 index 00000000..2944a3c1 --- /dev/null +++ b/src/lib/utils/assert.js @@ -0,0 +1,1189 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-use-before-define */ + +const assert = require('assert'); + +const constants = require('../constants'); + +/** + * @module utils/assert + * + * @typedef {import('./config').AdditionalOption} AdditionalOption + * @typedef {import('./config').Component} Component + * @typedef {import('./config').Connection} Connection + * @typedef {import('./config').Credentials} Credentials + * @typedef {import('./config').CredentialsPartial} CredentialsPartial + * @typedef {import('./config').SystemPollerEndpoint} CustomEndpoint + * @typedef {import('./config').DataActions} DataActions + * @typedef {import('../systemPoller/loader').Endpoint} Endpoint + * @typedef {import('./config').HttpAgentOption} HttpAgentOption + * @typedef {import('./config').IHealthPollerCompontent} IHealthPollerCompontent + * @typedef {import('./config').Proxy} Proxy + * @typedef {import('./config').PullConsumerSystemPollerGroup} PullConsumerSystemPollerGroup + * @typedef {import('./config').Secret} Secret + * @typedef {import('../storage/storage').Key} StorageKey + * @typedef {import('./config').SystemPollerComponent} SystemPollerComponent + * @typedef {import('./config').TraceConfig} TraceConfig + */ + +module.exports = { + // START OF HELP FUNCTIONS + + /** + * Asserts that all assertions from the list are passing + * + * @param {function[]} assertions + * @param {string} message + * + * @throws {Error} when assertion failed + */ + allOfAssertions(...assertions) { + assert(assertions.length > 0, 'should be at least one assertion function'); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + assertions.forEach((assertion, idx) => { + try { + assertion(); + } catch (error) { + error.message = `${message}assert.allOfAssertions: assertion #${idx + 1} failed to pass the test: [${error.message || error}]`; + assert.ifError(error); + } + }); + }, + + /** + * Asserts that is at least one assertion from the list is passing + * + * @param {function[]} assertions + * @param {string} message + * + * @throws {Error} first failed assertion + */ + anyOfAssertions(...assertions) { + assert(assertions.length > 0, 'should be at least one assertion function'); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + const errors = []; + const success = assertions.some((assertion) => { + try { + assertion(); + } catch (error) { + errors.push(`#${errors.length + 1}: ${error.message || error}`); + return false; + } + return true; + }); + + if (success === false) { + assert.ifError(new Error(`${message}assert.anyOfAssertions: none assertions from the list are passing the test: [${errors.join(', ')}]`)); + } + }, + + /** + * Asserts that only one assertion from the list is passing + * + * @param {function[]} assertions + * @param {string} message + */ + oneOfAssertions(...assertions) { + assert(assertions.length > 0, 'should be at least one assertion function'); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + let lastPassIdx = -1; + const errors = []; + + assertions.forEach((assertion, idx) => { + try { + assertion(); + } catch (error) { + errors.push(`#${errors.length + 1}: ${error.message || error}`); + // it is OK to return here + return; + } + // assertion passed, need to check if it is the only one passing the test or not + if (lastPassIdx === -1) { + lastPassIdx = idx; + } else { + assert.ifError(new Error(`${message}assert.oneOfAssertions: assertions #${lastPassIdx + 1} and #${idx + 1} are both passing the test`)); + } + }); + + if (lastPassIdx === -1) { + assert.ifError(new Error(`${message}assert.oneOfAssertions: none assertions from the list are passing the test: [${errors.join(', ')}]`)); + } + }, + + // END OF HELP FUNCTIONS + + /** + * Ensures the value is an array + * + * @property {any} value + * @property {string} vname + */ + array(value, vname) { + assert( + Array.isArray(value), + `${vname} should be an array` + ); + }, + + /** + * Ensures the value is a truthy value + * + * @param {any} value + * @param {string} vname + * @param {string} [msg] + */ + assert(value, vname, msg) { + assert(value, `${vname} ${msg || 'should result in truthy value'}`); + }, + + bigip: { + /** + * Ensures the BIG-IP connection object is valid (allows optional properties) + * + * @param {Connection} connection + * @param {string} vname + */ + connection(connection, vname) { + m.http.connection(connection, vname); + }, + + /** + * Ensures the BIG-IP connection object is strictly valid (all properties set) + * + * @param {Connection} connection + * @param {string} vname + */ + connectionStrict(connection, vname) { + m.allOfAssertions( + () => m.object(connection, vname), + () => m.boolean(connection.allowSelfSignedCert, `${vname}.allowSelfSignedCert`), + () => m.string(connection.host, `${vname}.host`), + () => m.safeNumberBetweenExclusive(connection.port, 0, 2 ** 16, `${vname}.port`), + () => m.string(connection.protocol, `${vname}.protocol`), + () => m.oneOf(connection.protocol, constants.HTTP_REQUEST.ALLOWED_PROTOCOLS, `${vname}.protocol`) + ); + }, + + /** + * Ensures the BIG-IP credentials object is valid + * + * @param {string} host + * @param {Credentials | CredentialsPartial} credentials + * @param {null | string} [credentials.token] - authorization token + * @param {string} vname + */ + credentials(host, credentials, vname) { + if (host === constants.LOCAL_HOST) { + m.oneOfAssertions( + () => m.not.defined(credentials, vname), + () => m.allOfAssertions( + () => m.object(credentials, vname), + () => m.oneOfAssertions( + () => m.not.exist(credentials.token, `${vname}.token`), // undefined or null are OK for localhost token + () => m.string(credentials.token, `${vname}.token`) + ), + () => optionalUserPass(credentials, vname) + ) + ); + } else { + m.allOfAssertions( + () => m.object(credentials, vname), + () => m.oneOfAssertions( + () => m.allOfAssertions( + () => m.not.defined(credentials.token, `${vname}.token`), + () => m.string(credentials.username, `${vname}.username`), + () => m.string(credentials.passphrase, `${vname}.passphrase`) + ), + () => m.allOfAssertions( + () => m.string(credentials.token, `${vname}.token`), + () => optionalUserPass(credentials, vname) + ) + ) + ); + } + }, + + /** + * Ensures the BIG-IP REST API custom endpoint object is valid + * + * @param {CustomEndpoint} endpoint + * @param {string} vname + */ + customEndpoint(endpoint, vname) { + m.config.customEndpoint(endpoint, vname); + }, + + /** + * Ensures the BIG-IP REST API endpoint object is valid + * + * @param {Endpoint} endpoint + * @param {string} vname + */ + endpoint(endpoint, vname) { + m.allOfAssertions( + () => m.object(endpoint, vname), + () => m.string(endpoint.path, `${vname}.path`), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.name, `${vname}.name`), + () => m.string(endpoint.name, `${vname}.name`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.body, `${vname}.body`), + () => m.string(endpoint.body, `${vname}.body`), + () => m.object(endpoint.body, `${vname}.body`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.ignoreCached, `${vname}.ignoreCached`), + () => m.boolean(endpoint.ignoreCached, `${vname}.ignoreCached`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.includeStats, `${vname}.includeStats`), + () => m.boolean(endpoint.includeStats, `${vname}.includeStats`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.parseDuplicateKeys, `${vname}.parseDuplicateKeys`), + () => m.boolean(endpoint.parseDuplicateKeys, `${vname}.parseDuplicateKeys`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.pagination, `${vname}.pagination`), + () => m.boolean(endpoint.pagination, `${vname}.pagination`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.query, `${vname}.query`), + () => m.object(endpoint.query, `${vname}.query`) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.expandReferences, `${vname}.expandReferences`), + () => m.allOfAssertions( + () => m.object(endpoint.expandReferences, `${vname}.expandReferences`), + () => Object.entries(endpoint.expandReferences).forEach(([ref, value]) => { + m.oneOfAssertions( + () => m.emptyObject(value, `${vname}.expandReferences.${ref}`), + () => m.allOfAssertions( + () => m.object(value, `${vname}.expandReferences.${ref}`), + () => m.oneOfAssertions( + () => m.not.defined(value.includeStats, `${vname}.expandReferences.${ref}.includeStats`), + () => m.boolean(value.includeStats, `${vname}.expandReferences.${ref}.includeStats`) + ), + () => m.oneOfAssertions( + () => m.not.defined(value.endpointSuffix, `${vname}.expandReferences.${ref}.includeStats`), + () => m.string(value.endpointSuffix, `${vname}.expandReferences.${ref}.includeStats`) + ) + ) + ); + }) + ) + ) + ); + } + }, + + /** + * Ensures the value is a boolean + * + * @property {any} value + * @property {string} vname + */ + boolean(value, vname) { + assert( + typeof value === 'boolean', + `${vname} should be a boolean` + ); + }, + + config: { + /** + * Ensures the value is an array of AdditionalOption + * + * @property {AdditionalOption[]} value + * @property {string} vname + */ + additionalOptions(value, vname) { + m.allOfAssertions( + () => m.array(value, vname), + () => m.not.empty(value, vname), + () => value.forEach((v, idx) => { + const iname = `${vname}[${idx}]`; + m.string(v.name, `${iname}.name`); + m.defined(v.value, `${iname}.value`); + }) + ); + }, + + /** + * Ensures the value is a Component type + * + * @property {Component} value + * @property {string} cls - component's class + * @property {string} vname + * @property {object} [options] - options + * @property {boolean} [options.noTrace = false] - do not check `trace` + */ + component(config, cls, vname, { noTrace = false } = {}) { + m.allOfAssertions( + () => m.object(config, vname), + () => m.oneOf(config.class, Object.values(constants.CONFIG_CLASSES), `${vname}.class`), + () => m.assert(config.class === cls, `${vname}.class`, `should be a "${cls}" string`), + () => m.boolean(config.enable, `${vname}.enable`), + () => m.string(config.id, `${vname}.id`), + () => m.string(config.name, `${vname}.name`), + () => m.string(config.namespace, `${vname}.namespace`), + () => m.string(config.traceName, `${vname}.traceName`), + () => !noTrace && m.config.traceConfg(config.trace, `${vname}.trace`) + ); + }, + + /** + * Ensures the value is a Connection type + * + * @param {Connection} connection + * @param {string} vname + */ + connection(connection, vname) { + m.allOfAssertions( + () => m.object(connection, vname), + () => m.boolean(connection.allowSelfSignedCert, `${vname}.allowSelfSignedCert`), + () => m.string(connection.host, `${vname}.host`), + () => m.safeNumberBetweenExclusive(connection.port, 0, 2 ** 16, `${vname}.port`), + () => m.string(connection.protocol, `${vname}.protocol`), + () => m.oneOf(connection.protocol, constants.HTTP_REQUEST.ALLOWED_PROTOCOLS, `${vname}.protocol`) + ); + }, + + /** + * Ensures the BIG-IP REST API custom endpoint object is valid + * + * @param {CustomEndpoint} endpoint + * @param {string} vname + */ + customEndpoint(endpoint, vname) { + m.allOfAssertions( + () => m.object(endpoint, vname), + () => m.boolean(endpoint.enable, `${vname}.enable`), + () => m.string(endpoint.name, `${vname}.name`), + () => m.string(endpoint.path, `${vname}.path`), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.protocol, `${vname}.protocol`), + () => m.allOfAssertions( + () => m.string(endpoint.protocol, `${vname}.protocol`), + () => m.oneOf(endpoint.protocol, ['http', 'snmp'], `${vname}.protocol`) + ) + ), + () => m.oneOfAssertions( + () => m.not.defined(endpoint.numericalEnums, `${vname}.numericalEnums`), + () => m.allOfAssertions( + () => m.assert(endpoint.protocol === 'snmp', `${vname}.protocol`), + () => m.boolean(endpoint.numericalEnums, `${vname}.numericalEnums`) + ) + ) + ); + }, + + /** + * Ensures the value is a DataActions type + * + * @param {DataActions} actions + * @param {string} vname + */ + dataActions(actions, vname) { + m.allOfAssertions( + () => m.array(actions, vname), + () => actions.forEach((action, idx) => { + const iname = `${vname}[${idx}]`; + + m.boolean(action.enable, `${iname}.enable`); + m.oneOfAssertions( + () => m.allOfAssertions( + () => m.oneOfAssertions( + () => m.emptyObject(action.setTag, `${iname}.setTag`), + () => m.object(action.setTag, `${iname}.setTag`) + ), + () => m.oneOfAssertions( + () => m.not.defined(action.locations, `${iname}.locations`), + () => m.emptyObject(action.locations, `${iname}.locations`), + () => m.object(action.locations, `${iname}.locations`) + ) + ), + () => m.allOfAssertions( + () => m.oneOfAssertions( + () => m.emptyObject(action.includeData, `${iname}.includeData`), + () => m.emptyObject(action.excludeData, `${iname}.excludeData`) + ), + () => m.oneOfAssertions( + () => m.emptyObject(action.locations, `${iname}.locations`), + () => m.object(action.locations, `${iname}.locations`) + ) + ) + ); + m.oneOfAssertions( + () => m.allOfAssertions( + () => m.not.defined(action.ifAllMatch, `${iname}.ifAllMatch`), + () => m.not.defined(action.ifAnyMatch, `${iname}.ifAnyMatch`) + ), + () => m.allOfAssertions( + () => m.not.defined(action.ifAllMatch, `${iname}.ifAllMatch`), + () => m.array(action.ifAnyMatch, `${iname}.ifAnyMatch`) + ), + () => m.allOfAssertions( + () => m.not.defined(action.ifAnyMatch, `${iname}.ifAnyMatch`), + () => m.oneOfAssertions( + () => m.emptyObject(action.ifAllMatch, `${iname}.ifAllMatch`), + () => m.object(action.ifAllMatch, `${iname}.ifAllMatch`) + ) + ) + ); + }) + ); + }, + + /** + * Ensures the value is a Credentials type + * + * @param {Credentials | CredentialsPartial} credentials + * @param {boolean} strict + * @param {string} vname + */ + credentials(credentials, strict, vname) { + if (strict) { + m.allOfAssertions( + () => m.object(credentials, vname), + () => m.string(credentials.username, `${vname}.username`), + () => m.oneOfAssertions( + () => m.string(credentials.passphrase, `${vname}.passphrase`), + () => m.config.secret(credentials.passphrase, `${vname}.passphrase`) + ) + ); + } else { + m.oneOfAssertions( + () => m.not.defined(credentials, vname), + () => m.allOfAssertions( + () => m.object(credentials, vname), + () => m.string(credentials.username, `${vname}.username`), + () => m.oneOfAssertions( + () => m.not.defined(credentials.passphrase, `${vname}.passphrase`), + () => m.string(credentials.passphrase, `${vname}.passphrase`), + () => m.config.secret(credentials.passphrase, `${vname}.passphrase`) + ) + ) + ); + } + }, + + /** + * Ensures the value is an array of HttpAgentOption + * + * @property {HttpAgentOption[]} value + * @property {string} vname + */ + httpAgentOptions(value, vname) { + m.oneOfAssertions( + () => m.not.defined(value, vname), + () => m.allOfAssertions( + () => m.config.additionalOptions(value, vname), + () => value.forEach((v, idx) => { + const iname = `${vname}[${idx}]`; + m.string(v.name, `${iname}.name`); + m.defined(v.value, `${iname}.value`); + + m.oneOfAssertions( + () => m.allOfAssertions( + () => m.assert(v.name === 'keepAlive', `${iname}.name`, 'should be "keepAlive"'), + () => m.boolean(v.value, `${iname}.value`) + ), + () => m.allOfAssertions( + () => m.oneOf(v.name, [ + 'keepAliveMsecs', + 'maxFreeSockets', + 'maxSockets' + ], `${iname}.name`), + () => m.safeNumberGrEq(v.value, 0, `${iname}.value`) + ) + ); + }) + ) + ); + }, + + /** + * Ensures the value is a IHealthPollerCompontent type + * + * @property {IHealthPollerCompontent} value + * @property {string} vname + */ + ihealthPoller(config, vname) { + m.allOfAssertions( + () => m.config.component(config, constants.CONFIG_CLASSES.IHEALTH_POLLER_CLASS_NAME, vname), + () => m.object(config.iHealth, `${vname}.iHealth`), + () => m.string(config.iHealth.name, `${vname}.iHealth.name`), + () => m.config.credentials(config.iHealth.credentials, true, `${vname}.iHealth.credentials`), + () => m.string(config.iHealth.downloadFolder, `${vname}.iHealth.downloadFolder`), + () => m.object(config.iHealth.interval, `${vname}.iHealth.interval`), + () => m.object(config.iHealth.interval.timeWindow, `${vname}.iHealth.interval.timeWindow`), + () => m.string(config.iHealth.interval.timeWindow.start, `${vname}.iHealth.interval.timeWindow.start`), + () => m.string(config.iHealth.interval.timeWindow.end, `${vname}.iHealth.interval.timeWindow.end`), + () => m.string(config.iHealth.interval.frequency, `${vname}.iHealth.interval.frequency`), + () => m.oneOfAssertions( + () => m.allOfAssertions( + () => m.assert(config.iHealth.interval.frequency === 'daily', `${vname}.iHealth.interval.frequency`), + () => m.not.defined(config.iHealth.interval.day, `${vname}.iHealth.interval.day`) + ), + () => m.allOfAssertions( + () => m.assert(config.iHealth.interval.frequency === 'weekly', `${vname}.iHealth.interval.frequency`), + () => m.oneOfAssertions( + () => m.string(config.iHealth.interval.day, `${vname}.iHealth.interval.day`), + () => m.safeNumberBetweenInclusive(config.iHealth.interval.day, 0, 7, `${vname}.iHealth.interval.day`) + ) + ), + () => m.allOfAssertions( + () => m.assert(config.iHealth.interval.frequency === 'monthly', `${vname}.iHealth.interval.frequency`), + () => m.safeNumberBetweenInclusive(config.iHealth.interval.day, 1, 31, `${vname}.iHealth.interval.day`) + ) + ), + () => m.oneOfAssertions( + () => m.not.defined(config.iHealth.proxy, `${vname}.iHealth.proxy`), + () => m.config.proxy(config.iHealth.proxy, `${vname}.iHealth.proxy`) + ), + () => m.object(config.system, `${vname}.system`), + () => m.string(config.system.name, `${vname}.system.name`), + () => m.config.connection(config.system.connection, `${vname}.system.connection`), + () => m.config.credentials(config.system.credentials, false, `${vname}.system.credentials`) + ); + }, + + /** + * Ensures the value is a Proxy type + * + * @param {Proxy} proxy + * @param {string} vname + */ + proxy(proxy, vname) { + m.allOfAssertions( + () => m.object(proxy, vname), + () => m.config.connection(proxy.connection, `${vname}.connection`), + () => m.config.credentials(proxy.credentials, false, `${vname}.credentials`) + ); + }, + + /** + * Ensures the value is a Proxy type + * + * @param {PullConsumerSystemPollerGroup} config + * @param {string} vname + */ + pullConsumerPollerGroup(config, vname) { + m.allOfAssertions( + () => m.config.component( + config, + constants.CONFIG_CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME, + vname, + { noTrace: true } + ), + () => m.string(config.pullConsumer, `${vname}.pullConsumer`), + () => m.string(config.pullConsumerName, `${vname}.pullConsumerName`), + () => m.array(config.systemPollers, `${vname}.systemPollers`), + () => m.oneOfAssertions( + () => m.empty(config.systemPollers, `${vname}.systemPollers`), + () => m.allOfAssertions( + ...config.systemPollers.map((sp, idx) => () => m.string(sp, `${vname}.systemPollers[${idx}]`)) + ) + ) + ); + }, + + /** + * Ensures the value is a Secret type + * + * @property {Secret} value + * @property {string} vname + */ + secret(secret, vname) { + m.allOfAssertions( + () => m.object(secret, vname), + () => m.string(secret.protected, `${vname}.protected`), + () => m.oneOf(secret.protected, ['plainText', 'plainBase64', 'SecureVault'], `${vname}.protected`), + () => m.oneOfAssertions( + () => m.allOfAssertions( + () => m.not.defined(secret.cipherText, `${vname}.cipherText`), + () => m.string(secret.environmentVar, `${vname}.environmentVar`) + ), + () => m.allOfAssertions( + () => m.string(secret.cipherText, `${vname}.cipherText`), + () => m.not.defined(secret.environmentVar, `${vname}.environmentVar`) + ) + ) + ); + }, + + /** + * Ensures the value is a SystemPollerComponent type + * + * @property {SystemPollerComponent} value + * @property {string} vname + */ + systemPoller(config, vname) { + m.allOfAssertions( + () => m.config.component(config, constants.CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME, vname), + () => m.string(config.systemName, `${vname}.systemName`), + () => m.config.connection(config.connection, `${vname}.connection`), + () => m.config.credentials(config.credentials, false, `${vname}.credentials`), + () => m.safeNumberGrEq(config.interval, 0, `${vname}.interval`), + () => m.safeNumberGrEq(config.workers, 1, `${vname}.workers`), + () => m.safeNumberGrEq(config.chunkSize, 1, `${vname}.chunkSize`), + () => m.object(config.dataOpts, `${vname}.dataOpts`), + () => m.boolean(config.dataOpts.noTMStats, `${vname}.dataOpts.noTMStats`), + () => m.oneOfAssertions( + () => m.not.defined(config.dataOpts.tags, `${vname}.dataOpts.tags`), + () => m.object(config.dataOpts.tags, `${vname}.dataOpts.tags`) + ), + () => m.config.dataActions(config.dataOpts.actions, `${vname}.dataOpts.actions`), + () => m.oneOfAssertions( + () => m.not.defined(config.endpoints, `${vname}.endpoints`), + () => m.emptyObject(config.endpoints, `${vname}.endpoints`), + () => m.allOfAssertions( + () => m.object(config.endpoints, `${vname}.endpoints`), + () => Object.entries(config.endpoints).forEach(([key, value]) => m.config.customEndpoint(value, `${vname}.endpoints[${key}]`)) + ) + ), + () => m.config.httpAgentOptions(config.httpAgentOpts, `${vname}.httpAgentOpts`) + ); + }, + + /** + * Ensures the value is a TraceConfig type + * + * @property {TraceConfig} value + * @property {string} vname + */ + traceConfg(config, vname) { + m.allOfAssertions( + () => m.object(config, vname), + () => m.boolean(config.enable, `${vname}.enable`), + () => m.string(config.encoding, `${vname}.encoding`), + () => m.safeNumber(config.maxRecords, `${vname}.maxRecords`), + () => m.string(config.path, `${vname}.path`), + () => m.string(config.type, `${vname}.type`), + () => m.oneOf(config.type, ['input', 'output'], `${vname}.type`) + ); + } + }, + + /** + * Ensures the value is defined (not undefined) + * + * @param {any} value + * @param {string} vname + */ + defined(value, vname) { + assert(typeof value !== 'undefined', `${vname} should not be undefined`); + }, + + /** + * Ensures the value is empty + * + * @param {Array | object | string} value + * @param {string} vname + */ + empty(value, vname) { + assert( + (typeof value === 'object' && value !== null && Object.keys(value).length === 0) + || (typeof value.length === 'number' && value.length === 0), + `${vname} should be an empty collection` + ); + }, + + /** + * Ensures the value is empty object + * + * @param {object} value + * @param {string} vname + */ + emptyObject(value, vname) { + assert(typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.keys(value).length === 0, + `${vname} should be an empty object`); + }, + + /** + * Ensures the value is neither null nor undefined + * + * @property {any} value + * @property {string} vname + */ + exist(value, vname) { + assert( + typeof value !== 'undefined' && value !== null, + `${vname} should be neither null or undefined` + ); + }, + + /** + * Ensures the value is a function + * + * @property {any} value + * @property {string} vname + */ + function(value, vname) { + assert(typeof value === 'function', `${vname} should be a function`); + }, + + http: { + /** + * Ensures the HTTP connection object is valid + * + * @param {Connection} connection + * @param {string} vname + */ + connection(connection, vname) { + m.allOfAssertions( + () => m.object(connection, vname), + () => m.oneOfAssertions( + () => m.not.defined(connection.allowSelfSignedCert, `${vname}.allowSelfSignedCert`), + () => m.boolean(connection.allowSelfSignedCert, `${vname}.allowSelfSignedCert`) + ), + () => m.oneOfAssertions( + () => m.not.defined(connection.port, `${vname}.port`), + () => m.safeNumberBetweenExclusive(connection.port, 0, 2 ** 16, `${vname}.port`) + ), + () => m.oneOfAssertions( + () => m.not.defined(connection.protocol, `${vname}.protocol`), + () => m.allOfAssertions( + () => m.string(connection.protocol, `${vname}.protocol`), + () => m.oneOf(connection.protocol, constants.HTTP_REQUEST.ALLOWED_PROTOCOLS, `${vname}.protocol`) + ) + ) + ); + }, + + /** + * Ensures the HTTP proxy object is valid + * + * @param {Proxy} proxy + * @param {string} vname + */ + proxy(proxy, vname) { + m.allOfAssertions( + () => m.object(proxy, vname), + () => m.http.connection(proxy.connection, `${vname}.connection`), + () => m.string(proxy.connection.host, `${vname}.connection.string`), + () => m.oneOfAssertions( + () => m.not.defined(proxy.credentials, `${vname}.credentials`), + () => m.allOfAssertions( + () => m.object(proxy.credentials, `${vname}.credentials`), + () => optionalUserPass(proxy.credentials, `${vname}.credentials`) + ) + ) + ); + } + }, + + ihealth: { + /** + * Ensures the iHealth credentials object is valid + * + * @param {Credentials} credentials + * @param {string} vname + */ + credentials(credentials, vname) { + m.allOfAssertions( + () => m.object(credentials, vname), + () => m.string(credentials.username, `${vname}.username`), + () => m.string(credentials.passphrase, `${vname}.passphrase`) + ); + }, + + /** + * Ensures the iHealth Report object is fulfilled with diagnostics data + * + * @param {object} report + * @param {string} vname + */ + diagnosticsReport(report, vname) { + m.allOfAssertions( + () => m.ihealth.report(report, vname), + () => m.assert(report.status.done === true, `${vname}.status.done`, 'should be true'), + () => m.assert(report.status.error === false, `${vname}.status.error`, 'should be false'), + () => m.object(report.metadata, `${vname}.metadata`), + () => m.safeNumberGr(report.metadata.cycleStart, 0, `${vname}.metadata.cycleStart`), + () => m.safeNumberGr(report.metadata.cycleEnd, 0, `${vname}.metadata.cycleEnd`), + () => m.string(report.metadata.qkviewURI, `${vname}.metadata.qkviewURI`) + ); + }, + + /** + * Ensures the iHealth Report object is valid + * + * @param {object} report + * @param {string} vname + */ + report(report, vname) { + m.allOfAssertions( + () => m.object(report, vname), + () => m.object(report.status, `${vname}.status`), + () => m.boolean(report.status.done, `${vname}.status.done`), + () => m.boolean(report.status.error, `${vname}.status.error`), + () => m.string(report.qkviewURI, `${vname}.qkviewURI`), + () => m.oneOfAssertions( + () => m.allOfAssertions( + () => m.assert(report.status.done === true, `${vname}.status.done`), + () => m.assert(report.status.error === true, `${vname}.status.error`), + () => m.string(report.status.errorMessage, `${vname}.status.errorMessage`), + () => m.not.defined(report.diagnosticsURI, `${vname}.diagnosticsURI`), + () => m.not.defined(report.diagnostics, `${vname}.diagnostics`) + ), + () => m.allOfAssertions( + () => m.assert(report.status.done === false, `${vname}.status.done`), + () => m.assert(report.status.error === false, `${vname}.status.error`), + () => m.not.defined(report.status.errorMessage, `${vname}.status.errorMessage`), + () => m.not.defined(report.diagnosticsURI, `${vname}.diagnosticsURI`), + () => m.not.defined(report.diagnostics, `${vname}.diagnostics`) + ), + () => m.allOfAssertions( + () => m.assert(report.status.done === true, `${vname}.status.done`), + () => m.assert(report.status.error === false, `${vname}.status.error`), + () => m.not.defined(report.status.errorMessage, `${vname}.status.errorMessage`), + () => m.string(report.diagnosticsURI, `${vname}.diagnosticsURI`), + () => m.object(report.diagnostics, `${vname}.diagnostics`) + ) + ) + ); + }, + + response: { + /** + * Ensures the iHealth Auth response data is valid + * + * @param {object} response + * @param {string} vname + */ + auth(response, vname) { + m.allOfAssertions( + () => m.object(response, vname), + () => m.string(response.access_token, `${vname}.access_token`), + () => m.safeNumberGr(response.expires_in, 0, `${vname}.expires_in`) + ); + }, + + /** + * Ensures the iHealth Qkview Diagnostics response data is valid + * + * @param {object} response + * @param {string} vname + */ + diagnostics(response, vname) { + m.allOfAssertions( + () => m.object(response, vname), + () => m.object(response.diagnostics, `${vname}.diagnostics`), + () => m.object(response.system_information, `${vname}.system_information`), + () => m.string(response.system_information.hostname, `${vname}.system_information.hostname`) + ); + }, + + /** + * Ensures the iHealth Qkview Report response data is valid + * + * @param {object} response + * @param {string} vname + */ + report(response, vname) { + m.allOfAssertions( + () => m.object(response, vname), + () => m.string(response.processing_status, `${vname}.processing_status`), + () => m.string(response.diagnostics, `${vname}.diagnostics`) + ); + }, + + /** + * Ensures the iHealth Qkview Upload response data is valid + * + * @param {object} response + * @param {string} vname + */ + upload(response, vname) { + m.allOfAssertions( + () => m.object(response, vname), + () => m.allOfAssertions( + () => m.string(response.result, `${vname}.result`), + () => m.assert(response.result === 'OK', `${vname}.result`, 'should be "OK"') + ), + () => m.string(response.location, `${vname}.location`) + ); + } + } + }, + + /** + * Ensures the value is instance of cls + * + * @property {any} value + * @property {object} cls + * @property {string} vname + */ + instanceOf(value, cls, vname) { + assert(value instanceof cls, `${vname} should be an instance of ${cls.name}`); + }, + + /** + * Ensures the value is an object + * + * @property {any} value + * @property {string} vname + */ + object(value, vname) { + this.allOfAssertions( + () => assert(typeof value === 'object', `${vname} should be an object`), + () => m.not.array(value, vname), + () => m.not.empty(value, vname) + ); + }, + + /** + * Ensures the value appears in the list + * + * @property {any} value + * @property {any[]} list + * @property {string} vname + */ + oneOf(value, list, vname) { + assert(list.includes(value), `${vname} should be one of ${list}`); + }, + + /** + * Emsures the value is a safe number + * + * @param {any} value + * @param {string} vname + */ + safeNumber(value, vname) { + assert(Number.isSafeInteger(value), `${vname} should be a safe number`); + }, + + /** + * Ensures `value` is a safe number and `lhs` < `value` < `rhs` + * + * @param {any} value + * @param {number} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberBetweenExclusive(value, lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(value, vname), + () => assert(lhs < value, `${vname} should be > ${lhs}, got ${value}`), + () => assert(value < rhs, `${vname} should be < ${rhs}, got ${value}`) + ); + }, + + /** + * Ensures `value` is a safe number and `lhs` <= `value` <= `rhs` + * + * @param {any} value + * @param {number} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberBetweenInclusive(value, lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(value, vname), + () => assert(lhs <= value, `${vname} should be >= ${lhs}, got ${value}`), + () => assert(value <= rhs, `${vname} should be <= ${rhs}, got ${value}`) + ); + }, + + /** + * Ensures `lhs` is a safe number and === `rhs` + * + * @param {any} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberEq(lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(lhs, vname), + () => assert(lhs === rhs, `${vname} should be === ${rhs}, got ${lhs}`) + ); + }, + + /** + * Ensures `lhs` is a safe number and >= `rhs` + * + * @param {any} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberGrEq(lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(lhs, vname), + () => assert(lhs >= rhs, `${vname} should be >= ${rhs}, got ${lhs}`) + ); + }, + + /** + * Ensures `lhs` is a safe number and > `rhs` + * + * @param {any} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberGr(lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(lhs, vname), + () => assert(lhs >= rhs, `${vname} should be > ${rhs}, got ${lhs}`) + ); + }, + + /** + * Ensures `lhs` is a safe number and <= `rhs` + * + * @param {any} lhs + * @param {number} rhs + * @param {string} vname + */ + safeNumberLsEq(lhs, rhs, vname) { + this.allOfAssertions( + () => m.safeNumber(lhs, vname), + () => assert(lhs <= rhs, `${vname} should be <= ${rhs}, got ${lhs}`) + ); + }, + + storage: { + /** + * Ensures the value is non-empty string + * + * @property {StorageKey} value + * @property {string} vname + */ + key(key, vname) { + m.oneOfAssertions( + () => m.string(key, vname), + () => m.allOfAssertions( + () => m.array(key, vname), + () => key.forEach((k, idx) => m.string(k, `${vname}[${idx}]`)) + ) + ); + } + }, + + /** + * Ensures the value is non-empty string + * + * @property {any} value + * @property {string} vname + */ + string(value, vname) { + this.allOfAssertions( + () => assert(typeof value === 'string', `${vname} should be a string`), + () => m.not.empty(value, vname) + ); + }, + + not: { + /** + * Ensures the value is not an array + * + * @param {any} value + * @param {string} vname + */ + array(value, vname) { + assert(!Array.isArray(value), `${vname} should not be an array`); + }, + + /** + * Ensures the value is not defined + * + * @param {any} value + * @param {string} vname + */ + defined(value, vname) { + assert(typeof value === 'undefined', `${vname} should be an undefined`); + }, + + /** + * Ensures the value is not empty + * + * @param {Array | object | string} value + * @param {string} vname + */ + empty(value, vname) { + assert( + (typeof value === 'object' && (value !== null && Object.keys(value).length > 0)) + || (typeof value.length === 'number' && value.length > 0), + `${vname} should be a non-empty collection` + ); + }, + + /** + * Ensures the value is neither null nor undefined + * + * @property {any} value + * @property {string} vname + */ + exist(value, vname) { + assert( + typeof value === 'undefined' || value === null, + `${vname} should be either null or undefined` + ); + } + } +}; + +function optionalUserPass(credentials, vname) { + return m.oneOfAssertions( + () => m.allOfAssertions( + () => m.not.defined(credentials.username, `${vname}.username`), + () => m.not.defined(credentials.passphrase, `${vname}.passphrase`) + ), + () => m.allOfAssertions( + () => m.string(credentials.username, `${vname}.username`), + () => m.oneOfAssertions( + () => m.not.defined(credentials.passphrase, `${vname}.passphrase`), + () => m.string(credentials.passphrase, `${vname}.passphrase`) + + ) + ) + ); +} + +const m = module.exports; diff --git a/src/lib/utils/config.js b/src/lib/utils/config.js index d492dea2..225acb2d 100644 --- a/src/lib/utils/config.js +++ b/src/lib/utils/config.js @@ -17,6 +17,7 @@ 'use strict'; const pathUtil = require('path'); +const crypto = require('crypto'); const constants = require('../constants'); const declValidator = require('../declarationValidator'); @@ -68,7 +69,7 @@ function getAllowSelfSignedCertVal(config) { * @param {Configuration} convertedConfig - object to save normalized config to */ function cleanupComponents(convertedConfig) { - _module.removeComponents(convertedConfig, { class: CLASSES.NAMESPACE_CLASS_NAME }); + removeComponents(convertedConfig, { class: CLASSES.NAMESPACE_CLASS_NAME }); } /** @@ -135,9 +136,9 @@ function componentizeConfig(originConfig, convertedConfig) { * These are for components that are omitted from the declaration * but we need to explicitly create later on for mapping * - * @returns {Promise} once got default values from the schema + * @returns {object} once got default values from the schema */ -function getComponentDefaults() { +async function getComponentDefaults() { const defaultDecl = { class: 'Telemetry', Telemetry_System: { @@ -233,10 +234,56 @@ function getTracerConfig(component, traceConfig) { * @returns {void} when unique ID generated for each Component within Configuration */ function generateComponentsIDs(convertedConfig) { - _module.getComponents(convertedConfig).forEach((component) => { - // ATTENTION: have to call 'generateUuid' with 'component' as context to be able to generate - // predictable IDs for testing - component.id = util.generateUuid.call(component); + _module.getComponents(convertedConfig) + .forEach((component) => { + // ATTENTION: have to call 'generateUuid' with 'component' as context to be able to generate + // predictable IDs for testing + component.id = util.generateUuid.call(component); + }); +} + +/** + * Get Telemetry_Endpoints objects + * + * @param {Configuration} config - config + * @param {FilterOptions} [filter] - filtering options + * + * @returns {Component[]} array of Telemetry_Endpoints objects + */ +function getTelemetryEndpoints(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.ENDPOINTS_CLASS_NAME + } + }); +} + +/** + * Get Telemetry_System objects + * + * @param {Configuration} config - config + * + * @returns {Component[]} array of Telemetry_System objects + */ +function getTelemetrySystems(config) { + return _module.getComponents(config, { class: CLASSES.SYSTEM_CLASS_NAME }); +} + +/** + * Get Telemetry_Pull_Consumer_System_Poller_Group objects + * + * @param {Configuration} config - config + * @param {FilterOptions} [filter] - filtering options + * + * @returns {Component[]} array of Telemetry_Pull_Consumer_System_Poller_Group objects + */ +function getTelemetryPullConsumerSystemPollerGroups(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME + } }); } @@ -247,8 +294,10 @@ function generateComponentsIDs(convertedConfig) { * @returns {boolean} true when Splunk consumer configured with "legacy" format */ function hasSplunkLegacy(config, namespace) { - return _module.getTelemetryConsumers(config, namespace) - .some((c) => c.type === 'Splunk' && c.format === 'legacy'); + return _module.getTelemetryConsumers(config, { + filter: (c) => c.type === 'Splunk' && c.format === 'legacy', + namespace + }).length > 0; } /** @@ -268,11 +317,12 @@ function mapComponents(convertedConfig) { if (useForMapping(component.class) && component.enable) { const receivers = []; if (component.class === CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME) { - const pullConsumer = _module.getTelemetryPullConsumers(convertedConfig, component.namespace) - .find((pc) => pc.id === component.pullConsumer); - receivers.push(pullConsumer); + receivers.push(_module.getTelemetryPullConsumers(convertedConfig, { + id: component.pullConsumer, + namespace: component.namespace + })[0]); } else if (!(component.class === CLASSES.SYSTEM_POLLER_CLASS_NAME && component.interval === 0)) { - _module.getTelemetryConsumers(convertedConfig, component.namespace) + _module.getTelemetryConsumers(convertedConfig, { namespace: component.namespace }) .forEach((c) => receivers.push(c)); } @@ -296,23 +346,21 @@ function mapComponents(convertedConfig) { * * @param {Configuration} convertedConfig - object to save normalized config to * - * @returns {Promise} resolved once configuration normalized + * @returns {void} once configuration normalized */ -function normalizeComponents(convertedConfig) { +async function normalizeComponents(convertedConfig) { // ensure we're getting our "base" config for missing components based on schema - return getComponentDefaults() - .then((defaults) => { - normalizeTelemetrySystems(convertedConfig); - normalizeTelemetrySystemPollers(convertedConfig, defaults); - normalizeTelemetryPullConsumerSystemPollerGroups(convertedConfig); - normalizeTelemetryIHealthPollers(convertedConfig, defaults); - normalizeTelemetryEndpoints(convertedConfig); - normalizeTelemetryListeners(convertedConfig); - normalizeTelemetryConsumers(convertedConfig); - normalizeTelemetryPullConsumers(convertedConfig); - generateComponentsIDs(convertedConfig); - postProcessTelemetryPullConsumerSystemPollerGroups(convertedConfig); - }); + const defaults = await getComponentDefaults(); + normalizeTelemetrySystems(convertedConfig); + normalizeTelemetrySystemPollers(convertedConfig, defaults); + normalizeTelemetryPullConsumerSystemPollerGroups(convertedConfig); + normalizeTelemetryIHealthPollers(convertedConfig, defaults); + normalizeTelemetryEndpoints(convertedConfig); + normalizeTelemetryListeners(convertedConfig); + normalizeTelemetryConsumers(convertedConfig); + normalizeTelemetryPullConsumers(convertedConfig); + generateComponentsIDs(convertedConfig); + postProcessTelemetryPullConsumerSystemPollerGroups(convertedConfig); } /** @@ -404,7 +452,7 @@ function normalizeTelemetryEndpoints(convertedConfig) { } telemetrySystemPollers.forEach((poller) => { - telemetryEndpoints = _module.getTelemetryEndpoints(convertedConfig, poller.namespace); + telemetryEndpoints = getTelemetryEndpoints(convertedConfig, { namespace: poller.namespace }); if (Object.prototype.hasOwnProperty.call(poller, 'endpointList')) { const endpoints = {}; processEndpoint(poller.endpointList, (endpoint) => { @@ -426,7 +474,7 @@ function normalizeTelemetryEndpoints(convertedConfig) { }); // remove those endpoints - we don't need them any more - _module.removeComponents(convertedConfig, { class: CLASSES.ENDPOINTS_CLASS_NAME }); + removeComponents(convertedConfig, { class: CLASSES.ENDPOINTS_CLASS_NAME }); } /** @@ -448,10 +496,11 @@ function normalizeTelemetryIHealthPollers(convertedConfig) { * - Telemetry_iHealth_Poller has no ability to configure host, port and etc. * and should be attached to Telemetry_System instead */ - const pollersWithoutSystem = _module.getTelemetryIHealthPollers(convertedConfig) - .filter((poller) => !poller.systemName); + const pollersWithoutSystem = _module + .getTelemetryIHealthPollers(convertedConfig, { filter: (p) => typeof p.system === 'undefined' }); + // remove those pollers - we don't need them any more - _module.removeComponents(convertedConfig, { + removeComponents(convertedConfig, { class: CLASSES.IHEALTH_POLLER_CLASS_NAME, filter: (c) => pollersWithoutSystem.indexOf(c) !== -1 }); @@ -548,12 +597,13 @@ function normalizeTelemetryPullConsumerSystemPollerGroups(convertedConfig) { * without checking for existence * - safe to refer to a pull consumer by name within namespace scope */ - const systemPollerGroups = _module.getTelemetryPullConsumerSystemPollerGroups(convertedConfig); - const pullConsumers = _module.getTelemetryPullConsumers(convertedConfig); + const systemPollerGroups = getTelemetryPullConsumerSystemPollerGroups(convertedConfig); systemPollerGroups.forEach((pollerGroup) => { - const pullConsumer = pullConsumers - .find((pc) => pc.namespace === pollerGroup.namespace && pc.name === pollerGroup.pullConsumer); + const pullConsumer = _module.getTelemetryPullConsumers(convertedConfig, { + name: pollerGroup.pullConsumer, + namespace: pollerGroup.namespace + })[0]; pollerGroup.enable = pullConsumer.enable; pollerGroup.name = `${pollerGroup.class}_${pullConsumer.name}`; @@ -581,12 +631,12 @@ function normalizeTelemetryPullConsumerSystemPollerGroups(convertedConfig) { * @returns {void} once Telemetry_System objects normalized */ function normalizeTelemetrySystems(convertedConfig) { - const telemetrySystems = _module.getTelemetrySystems(convertedConfig); + const telemetrySystems = getTelemetrySystems(convertedConfig); const preExistingTelemetrySystemPollers = _module.getTelemetrySystemPollers(convertedConfig); const preExistingTelemetryIHealthPollers = _module.getTelemetryIHealthPollers(convertedConfig); // remove Telemetry_Systems from the array of components - _module.removeComponents(convertedConfig, { class: CLASSES.SYSTEM_CLASS_NAME }); + removeComponents(convertedConfig, { class: CLASSES.SYSTEM_CLASS_NAME }); let nameID; let existingNames; @@ -623,10 +673,10 @@ function normalizeTelemetrySystems(convertedConfig) { // systemPoller is a nested object, so extract out into separate component pollerObj = systemPoller; // refresh list of existing name within system - existingNames = _module.getTelemetrySystemPollers( - convertedConfig, - (p) => p.namespace === system.namespace && p.systemName === system.name - ).map((p) => p.name); + existingNames = _module.getTelemetrySystemPollers(convertedConfig, { + namespace: system.namespace, + filter: (p) => p.systemName === system.name + }).map((p) => p.name); assignPollerName('SystemPoller', pollerObj); } pollerObj = updateSystemPollerConfig(system, util.deepCopy(pollerObj), fetchTMStats); @@ -650,13 +700,11 @@ function normalizeTelemetrySystems(convertedConfig) { } pollerObj.usedAsRef = true; } else { - // systemPoller is a nested object, so extract out into separate component + // iHealth is a nested object, so extract out into separate component pollerObj = iHealthPoller; - // refresh list of existing name within system - existingNames = _module.getTelemetryIHealthPollers( - convertedConfig, - (p) => p.namespace === system.namespace && p.systemName === system.name - ).map((p) => p.name); + // set to empty list becuase only 1 iHealth Poller per System allowed to be configured + // no need to search for other iHealth pollers withing the system's scope + existingNames = []; assignPollerName('iHealthPoller', pollerObj); } pollerObj = updateIHealthPollerConfig(system, util.deepCopy(pollerObj)); @@ -679,10 +727,13 @@ function normalizeTelemetrySystems(convertedConfig) { * @returns {void} once Telemetry_System_Poller objects normalized */ function normalizeTelemetrySystemPollers(convertedConfig, componentDefaults) { - const pollersWithoutSystem = _module.getTelemetrySystemPollers(convertedConfig) - .filter((poller) => !poller.systemName); + const pollersWithoutSystem = _module + .getTelemetrySystemPollers(convertedConfig, { + filter: (poller) => !poller.systemName + }); + // remove those pollers - we don't need them any more - _module.removeComponents(convertedConfig, { + removeComponents(convertedConfig, { class: CLASSES.SYSTEM_POLLER_CLASS_NAME, filter: (c) => pollersWithoutSystem.indexOf(c) !== -1 }); @@ -720,20 +771,73 @@ function normalizeTelemetrySystemPollers(convertedConfig, componentDefaults) { */ function postProcessTelemetryPullConsumerSystemPollerGroups(convertedConfig) { // at that point IDs should be generated already - _module.getTelemetryPullConsumerSystemPollerGroups(convertedConfig) + getTelemetryPullConsumerSystemPollerGroups(convertedConfig) .forEach((pg) => { // updating pull consumer info - const pullConsumer = _module.getTelemetryPullConsumers(convertedConfig, pg.namespace) - .find((pc) => pc.name === pg.pullConsumer); + const pullConsumer = _module.getTelemetryPullConsumers(convertedConfig, { + name: pg.pullConsumer, + namespace: pg.namespace + })[0]; // convert all names/refs to IDs pg.pullConsumer = pullConsumer.id; - pg.systemPollers = _module.getTelemetrySystemPollers(convertedConfig, pg.namespace) - .filter((sp) => sp.enable && pg.systemPollers.indexOf(sp.name) !== -1) + pg.pullConsumerName = pullConsumer.traceName; + pg.systemPollers = _module.getTelemetrySystemPollers(convertedConfig, { + filter: (sp) => sp.enable, + name: pg.systemPollers, + namespace: pg.namespace + }) .map((sp) => sp.id); }); } +/** + * Remove components from .components and .mappings + * + * Note: This method mutates 'config'. + * + * @param {Configuration} config - config + * @param {FilterOptions} [filter] - filterting options + * + * @returns {void} once components removed + */ +function removeComponents(config, filter) { + if (!config || !config.components) { + return; + } + const componentsToRemove = _module.getComponents(config, filter); + if (componentsToRemove.length === 0) { + return; + } + if (config.components.length === componentsToRemove.length) { + config.components = []; + config.mappings = {}; + return; + } + config.components = config.components.filter((c) => componentsToRemove.indexOf(c) === -1); + if (util.isObjectEmpty(config.mappings)) { + return; + } + componentsToRemove.forEach((component) => { + if (util.isObjectEmpty(config.mappings)) { + return; + } + if (config.mappings[component.id]) { + delete config.mappings[component.id]; + } else { + Object.keys(config.mappings).forEach((id) => { + const index = config.mappings[id].indexOf(component.id); + if (index !== -1) { + config.mappings[id].splice(index, 1); + } + if (config.mappings[id].length === 0) { + delete config.mappings[id]; + } + }); + } + }); +} + /** * Note: This method mutates 'pollerConfig'. * @@ -751,20 +855,27 @@ function updateSystemPollerConfig(systemConfig, pollerConfig, fetchTMStats) { pollerConfig.trace = getPollerTraceValue(systemConfig.trace, pollerConfig.trace); pollerConfig.traceName = `${getTracePrefix(pollerConfig)}${systemConfig.name}::${pollerConfig.name}`; pollerConfig.connection = { - host: systemConfig.host, - port: systemConfig.port, - protocol: systemConfig.protocol, + host: systemConfig.host, // has default value from the schema + port: systemConfig.port, // has default value from the schema + protocol: systemConfig.protocol, // has default value from the schema allowSelfSignedCert: getAllowSelfSignedCertVal(systemConfig) }; pollerConfig.dataOpts = { - tags: pollerConfig.tag, actions: pollerConfig.actions, noTMStats: !fetchTMStats }; - pollerConfig.credentials = { - username: systemConfig.username, - passphrase: systemConfig.passphrase - }; + if (typeof pollerConfig.tag === 'object') { + pollerConfig.dataOpts.tags = pollerConfig.tag; + } + + if (typeof systemConfig.username !== 'undefined') { + pollerConfig.credentials = { + username: systemConfig.username + }; + if (typeof systemConfig.passphrase !== 'undefined') { + pollerConfig.credentials.passphrase = systemConfig.passphrase; + } + } pollerConfig.trace = getTracerConfig(pollerConfig); POLLER_KEYS.toDelete.forEach((key) => { delete pollerConfig[key]; @@ -784,55 +895,71 @@ function updateIHealthPollerConfig(systemConfig, pollerConfig) { pollerConfig.class = CLASSES.IHEALTH_POLLER_CLASS_NAME; pollerConfig.enable = !!systemConfig.enable && !!pollerConfig.enable; pollerConfig.namespace = systemConfig.namespace; - pollerConfig.systemName = systemConfig.name; pollerConfig.trace = getPollerTraceValue(systemConfig.trace, pollerConfig.trace); pollerConfig.traceName = `${getTracePrefix(pollerConfig)}${systemConfig.name}::${pollerConfig.name}`; + pollerConfig.iHealth = { name: pollerConfig.name, credentials: { username: pollerConfig.username, passphrase: pollerConfig.passphrase }, - downloadFolder: pollerConfig.downloadFolder, + downloadFolder: pollerConfig.downloadFolder || constants.DEVICE_TMP_DIR, interval: { timeWindow: { start: pollerConfig.interval.timeWindow.start, end: pollerConfig.interval.timeWindow.end }, - frequency: pollerConfig.interval.frequency, - day: pollerConfig.interval.day + frequency: pollerConfig.interval.frequency } }; - const ihProxy = pollerConfig.proxy || {}; - pollerConfig.iHealth.proxy = { - connection: { - host: ihProxy.host, - port: ihProxy.port, - protocol: ihProxy.protocol, - allowSelfSignedCert: getAllowSelfSignedCertVal(ihProxy) - }, - credentials: { - username: ihProxy.username, - passphrase: ihProxy.passphrase + if (typeof pollerConfig.interval.day !== 'undefined') { + pollerConfig.iHealth.interval.day = pollerConfig.interval.day; + } + + if (typeof pollerConfig.proxy === 'object') { + const ihProxy = pollerConfig.proxy; + pollerConfig.iHealth.proxy = { + connection: { + host: ihProxy.host, // required by the schema + port: ihProxy.port, // has default value from the schema + protocol: ihProxy.protocol, // has default value from the schema + allowSelfSignedCert: getAllowSelfSignedCertVal(ihProxy) + } + }; + if (typeof ihProxy.username === 'string') { + pollerConfig.iHealth.proxy.credentials = { + username: ihProxy.username + }; + if (typeof ihProxy.passphrase !== 'undefined') { + pollerConfig.iHealth.proxy.credentials.passphrase = ihProxy.passphrase; + } } - }; + } + pollerConfig.system = { name: systemConfig.name, - host: systemConfig.host, connection: { - port: systemConfig.port, - protocol: systemConfig.protocol, + host: systemConfig.host, // has default value from the schema + port: systemConfig.port, // has default value from the schema + protocol: systemConfig.protocol, // has default value from the schema allowSelfSignedCert: getAllowSelfSignedCertVal(systemConfig) - }, - credentials: { - username: systemConfig.username, - passphrase: systemConfig.passphrase } }; + if (typeof systemConfig.username !== 'undefined') { + pollerConfig.system.credentials = { + username: systemConfig.username + }; + if (typeof systemConfig.passphrase !== 'undefined') { + pollerConfig.system.credentials.passphrase = systemConfig.passphrase; + } + } pollerConfig.trace = getTracerConfig(pollerConfig); + IHEALTH_POLLER_KEYS.toDelete.forEach((key) => { delete pollerConfig[key]; }); + return pollerConfig; } @@ -860,7 +987,6 @@ _module = module.exports = { * * Note: this method mutates 'data' * - * @public * @param {object} data - declaration or configuration to decrypt * * @returns {Promise} resolved with decrypted secrets @@ -875,79 +1001,103 @@ _module = module.exports = { * Note: 'className' or/and 'namespace' might be functions that will * be passed directly to Array.prototype.filter * - * @public * @param {Configuration} config - config - * @param {object} [options] - options - * @param {string | function} [options.class] - class name or function to use as filter - * @param {function} [options.filter] - function to use as filter - * @param {string | function} [options.namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of components + * @returns {Component[]} array of components */ - getComponents(config, options) { + getComponents(config, filter = {}) { if (!config || !config.components) { return []; } let components = config.components; - options = options || {}; - if (options.class) { - let filter = options.class; - if (typeof filter !== 'function') { - filter = (c) => c.class === options.class; + + ['class', 'id', 'name', 'namespace'].forEach((prop) => { + const fprop = filter[prop]; + let fn; + if (typeof fprop === 'string') { + fn = (c) => c[prop] === fprop; + } else if (typeof fprop === 'function') { + fn = (c) => fprop(c[prop]); + } else if (Array.isArray(fprop)) { + fn = (c) => fprop.includes(c[prop]); } - components = components.filter(filter); - } - if (options.namespace) { - let filter = options.namespace; - if (typeof filter !== 'function') { - filter = (c) => c.namespace === options.namespace; + if (fn) { + components = components.filter(fn); } - components = components.filter(filter); - } - if (options.filter) { - components = components.filter(options.filter); + }); + + if (typeof filter.filter === 'function') { + components = components.filter(filter.filter); } + return components; }, /** - * Get configured and enabled receivers for component + * Generates SHA256 hash for the Component object * - * @public - * @param {Configuration} config - config - * @param {Component} component - component + * Sorts keys in A-Z order and uses values as an input data. + * Repeats the process recursevly. * - * @returns {Array} array of receivers + * NOTE: + * - use decrypted config for determenistic results + * - currently tested and verfied for following classes + * - Telemetry_iHealth_Poller + * - IGNORES following top-level properties (not sensitive data): + * - id + * - enable + * - trace + * - IGNORES folloving values: + * - null + * - undefined + * + * @param {Component} config + * + * @returns {string} SHA256 hash */ - getReceivers(config, component) { - const ids = config.mappings[component.id]; - if (!Array.isArray(ids) || ids.length === 0) { - return []; + getComponentHash(config) { + const values = []; + const ignoreKeys = ['enable', 'id', 'trace']; + const ignoreValues = [null, undefined]; // should never appear in the config, not allowed by the schema + + function grab(data, pkey = '', level = 0) { + if (Array.isArray(data)) { + data.forEach((item, idx) => grab(item, `${pkey}.${idx}`, level + 1)); + } else if (typeof data === 'object' && data !== null) { + let keys = Object.keys(data); + if (level === 0) { + keys = keys.filter((k) => !ignoreKeys.includes(k)); + } + keys.sort() + .forEach((key) => grab(data[key], `${pkey}.${key}`, level + 1)); + } else if (!ignoreValues.includes(data)) { + values.push(pkey, data); + } } - return _module.getComponents(config, { - filter: (c) => ids.indexOf(c.id) !== -1, - namespace: component.namespace - }); + + grab(config); + + const hash = crypto.createHash('sha256'); + hash.update(values.join('')); + return hash.digest('hex'); }, /** - * Get configured and enabled data sources for component + * Get configured and enabled receivers for component * - * @public * @param {Configuration} config - config * @param {Component} component - component * - * @returns {Array} array of data sources + * @returns {Component[]} array of receivers */ - getSources(config, component) { - const ids = Object.keys(config.mappings) - .filter((id) => config.mappings[id].indexOf(component.id) !== -1); - - if (ids.length === 0) { + getReceivers(config, component) { + const ids = config.mappings[component.id]; + if (!Array.isArray(ids) || ids.length === 0) { return []; } return _module.getComponents(config, { - filter: (c) => ids.indexOf(c.id) !== -1, + id: ids, namespace: component.namespace }); }, @@ -955,20 +1105,23 @@ _module = module.exports = { /** * Get Telemetry_Consumer objects * - * @public * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of Telemetry_Consumer objects + * @returns {Component[]} array of Telemetry_Consumer objects */ - getTelemetryConsumers(config, namespace) { - return _module.getComponents(config, { class: CLASSES.CONSUMER_CLASS_NAME, namespace }); + getTelemetryConsumers(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.CONSUMER_CLASS_NAME + } + }); }, /** * Get controls object from config * - * @public * @param {Configuration} config - the config to lookup the controls from * * @returns {Component} the controls object if exists, otherwise {} @@ -984,121 +1137,96 @@ _module = module.exports = { /** * Get Telemetry_iHealth_Poller objects * - * @public * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of Telemetry_iHealth_Poller objects + * @returns {Component[]} array of Telemetry_iHealth_Poller objects */ - getTelemetryIHealthPollers(config, namespace) { - return _module.getComponents(config, { class: CLASSES.IHEALTH_POLLER_CLASS_NAME, namespace }); - }, - - /** - * Get Telemetry_Endpoints objects - * - * @public - * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter - * - * @returns {Array} array of Telemetry_Endpoints objects - */ - getTelemetryEndpoints(config, namespace) { - return _module.getComponents(config, { class: CLASSES.ENDPOINTS_CLASS_NAME, namespace }); + getTelemetryIHealthPollers(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.IHEALTH_POLLER_CLASS_NAME + } + }); }, /** * Get Telemetry_Listener objects * - * @public * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of Telemetry_Listener objects + * @returns {Component[]} array of Telemetry_Listener objects */ - getTelemetryListeners(config, namespace) { - return _module.getComponents(config, { class: CLASSES.EVENT_LISTENER_CLASS_NAME, namespace }); + getTelemetryListeners(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.EVENT_LISTENER_CLASS_NAME + } + }); }, /** * Get Telemetry_Pull_Consumer objects * - * @public - * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter - * - * @returns {Array} array of Telemetry_Pull_Consumer objects - */ - getTelemetryPullConsumers(config, namespace) { - return _module.getComponents(config, { class: CLASSES.PULL_CONSUMER_CLASS_NAME, namespace }); - }, - - /** - * Get Telemetry_System objects - * - * @public * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of Telemetry_System objects + * @returns {Component[]} array of Telemetry_Pull_Consumer objects */ - getTelemetrySystems(config, namespace) { - return _module.getComponents(config, { class: CLASSES.SYSTEM_CLASS_NAME, namespace }); + getTelemetryPullConsumers(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.PULL_CONSUMER_CLASS_NAME + } + }); }, /** * Get Telemetry_System_Poller objects * - * @public * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter + * @param {FilterOptions} [filter] - filtering options * - * @returns {Array} array of Telemetry_System_Poller objects + * @returns {Component[]} array of Telemetry_System_Poller objects */ - getTelemetrySystemPollers(config, namespace) { - return _module.getComponents(config, { class: CLASSES.SYSTEM_POLLER_CLASS_NAME, namespace }); + getTelemetrySystemPollers(config, filter = {}) { + return _module.getComponents(config, { + ...filter, + ...{ + class: CLASSES.SYSTEM_POLLER_CLASS_NAME + } + }); }, /** * Get Telemetry_System_Poller objects for Telemetry_Pull_Consumer_System_Poller_Group * - * @public * @param {Configuration} config - config * @param {Component} pollerGroup - Telemetry_Pull_Consumer_System_Poller_Group object * - * @returns {Array} array of Telemetry_System_Poller objects + * @returns {Component[]} array of Telemetry_System_Poller objects */ getTelemetrySystemPollersForGroup(config, pollerGroup) { - return _module.getTelemetrySystemPollers(config, pollerGroup.namespace) - .filter((sp) => pollerGroup.systemPollers.indexOf(sp.id) !== -1); - }, - - /** - * Get Telemetry_Pull_Consumer_System_Poller_Group objects - * - * @public - * @param {Configuration} config - config - * @param {string | function} [namespace] - namespace name or function to use as filter - * - * @returns {Array} array of Telemetry_Pull_Consumer_System_Poller_Group objects - */ - getTelemetryPullConsumerSystemPollerGroups(config, namespace) { - return _module.getComponents(config, { - class: CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME, namespace + return _module.getTelemetrySystemPollers(config, { + id: pollerGroup.systemPollers, + namespace: pollerGroup.namespace }); }, /** * Get Telemetry_Pull_Consumer_System_Poller_Group object for Telemetry_Pull_Consumer * - * @public * @param {Configuration} config - config * @param {Component} pullConsumer - Telemetry_Pull_Consumer object * * @returns {Component | undefined} Telemetry_Pull_Consumer_System_Poller_Group object */ - getTelemetryPullConsumerSystemPollerGroupForPullConsumer(config, pullConsumer) { - return _module.getTelemetryPullConsumerSystemPollerGroups(config, pullConsumer.namespace) + getTelemetryPullConsumerSystemPollerGroup(config, pullConsumer) { + return getTelemetryPullConsumerSystemPollerGroups(config, { namespace: pullConsumer.namespace }) .filter((pg) => pg.pullConsumer === pullConsumer.id && config.mappings[pg.id]) .find((pg) => config.mappings[pg.id].indexOf(pullConsumer.id) !== -1); }, @@ -1106,7 +1234,6 @@ _module = module.exports = { /** * Get JSON Schema validation functions * - * @public * @param {boolean} [rebuildCache = false] - re-build cached validators * * @returns {SchemaValidatorFunctions} available config validation functions @@ -1121,17 +1248,14 @@ _module = module.exports = { /** * Check if config has "enabled" components * - * @public * @param {Configuration} config - config - * @param {object} [options] - options - * @param {string | function} [options.class] - class name or function to use as filter - * @param {function} [options.filter] - function to use as filter - * @param {string | function} [options.namespace] - namespace name or function to use as filter * * @returns {boolean} true if config has "enabled" components */ - hasEnabledComponents(config, options) { - return _module.getComponents(config, options).some((c) => c.class !== CLASSES.CONTROLS_CLASS_NAME && c.enable); + hasEnabledComponents(config) { + return _module.getComponents(config, { + filter: (c) => c.class !== CLASSES.CONTROLS_CLASS_NAME && c.enable + }).length > 0; }, /** @@ -1140,26 +1264,24 @@ _module = module.exports = { * * Note: This method mutates 'config'. * - * @public * @param {object} declaration - declaration to merge * @param {Configuration} config - config to use to merge to * - * @returns {Promise} resolve with merged normalized config + * @returns {Configuration} resolve with merged normalized config */ - mergeDeclaration(declaration, config) { - return _module.normalizeDeclaration(declaration, true) - .then((newNormalizedConfig) => { - // all objects have .namespace property - get all namespaces in new config - const namespaces = _module.getComponents(newNormalizedConfig) - .map((c) => c.namespace) - .filter((val, index, self) => self.indexOf(val) === index); - // remove namespaces from existing config - _module.removeComponents(config, { namespace: (c) => namespaces.indexOf(c.namespace) !== -1 }); - cleanupComponents(newNormalizedConfig); - config.mappings = Object.assign(config.mappings, newNormalizedConfig.mappings); - config.components = config.components.concat(newNormalizedConfig.components); - return config; - }); + async mergeDeclaration(declaration, config) { + const newNormalizedConfig = await _module.normalizeDeclaration(declaration, true); + // all objects have .namespace property - get all namespaces in new config + const namespaces = _module.getComponents(newNormalizedConfig) + .map((c) => c.namespace) + .filter((val, index, self) => self.indexOf(val) === index); + // remove namespaces from existing config + removeComponents(config, { namespace: namespaces }); + cleanupComponents(newNormalizedConfig); + config.mappings = Object.assign(config.mappings, newNormalizedConfig.mappings); + config.components = config.components.concat(newNormalizedConfig.components); + + return config; }, /** @@ -1167,77 +1289,27 @@ _module = module.exports = { * - polymorphic components like poller, systems and endpoints are normalized * - mappings for components are added for easy lookup * - * @public * @param {object} declaration - the config to normalize (declared components must be expanded) * @param {boolean} [noCleanup = false] - ignore cleanup * * @returns {Promise} normalized config */ - normalizeDeclaration(declaration, noCleanup) { + async normalizeDeclaration(declaration, noCleanup = false) { const convertedConfig = { mappings: {}, components: [] }; - if (util.isObjectEmpty(declaration) || Array.isArray(declaration) || typeof declaration !== 'object') { - return Promise.resolve(convertedConfig); - } - return Promise.resolve() - .then(() => componentizeConfig(declaration, convertedConfig)) - .then(() => normalizeComponents(convertedConfig)) - .then(() => mapComponents(convertedConfig)) - .then(() => (noCleanup ? null : cleanupComponents(convertedConfig))) - .then(() => convertedConfig); - }, + if (!(util.isObjectEmpty(declaration) || Array.isArray(declaration) || typeof declaration !== 'object')) { + componentizeConfig(declaration, convertedConfig); + await normalizeComponents(convertedConfig); + mapComponents(convertedConfig); - /** - * Remove components from .components and .mappings - * - * Note: This method mutates 'config'. - * - * @public - * @param {Configuration} config - config - * @param {object} [options] - options - * @param {string | function} [options.class] - class name or function to use as filter - * @param {function} [options.filter] - function to use as filter - * @param {string | function} [options.namespace] - namespace name or function to use as filter - * - * @returns {void} once components removed - */ - removeComponents(config, options) { - if (!config || !config.components) { - return; - } - const componentsToRemove = _module.getComponents(config, options); - if (componentsToRemove.length === 0) { - return; - } - if (config.components.length === componentsToRemove.length) { - config.components = []; - config.mappings = {}; - return; - } - config.components = config.components.filter((c) => componentsToRemove.indexOf(c) === -1); - if (util.isObjectEmpty(config.mappings)) { - return; - } - componentsToRemove.forEach((component) => { - if (util.isObjectEmpty(config.mappings)) { - return; - } - if (config.mappings[component.id]) { - delete config.mappings[component.id]; - } else { - Object.keys(config.mappings).forEach((id) => { - const index = config.mappings[id].indexOf(component.id); - if (index !== -1) { - config.mappings[id].splice(index, 1); - } - if (config.mappings[id].length === 0) { - delete config.mappings[id]; - } - }); + if (!noCleanup) { + cleanupComponents(convertedConfig); } - }); + } + + return convertedConfig; }, /** @@ -1245,29 +1317,32 @@ _module = module.exports = { * * Note: this method mutates 'data' * - * @public * @param {object} validator - the validator function to use * @param {object} data - data to validate against config schema * @param {object} [context] - context to pass to validator * - * @returns {Promise} Promise which is resolved with the validated schema + * @returns {object} the validated data */ - validate(validator, data, context) { + async validate(validator, data, context) { return declValidator.validate(validator, data, context); } }; /** - * @typedef Component - * @type {object} + * @typedef {object} AdditionalOption + * @property {string} name + * @property {any} value + */ +/** + * @typedef {object} Component * @property {string} class - class name a component belongs to + * @property {boolean} enable - true if component enabled * @property {string} id - unique ID for config's current life span * @property {string} name - name * @property {string} namespace - namespace a component belongs to + * @property {TraceConfig} trace - tracer's cofnig * @property {string} traceName - unique name computed using namespace and object's name, * should be used for logging and etc. - * @property {boolean} [enable] - true if component enabled - * @property {TraceConfig} [trace] - true if 'trace' enabled * * Note: * - component will have newly generated 'id' every time when: @@ -1280,10 +1355,9 @@ _module = module.exports = { * - if poller has 0 interval it will not be mapped to Telemetry_Consumer */ /** - * @typedef Configuration - * @type {object} - * @property {Array} components - configuration components - * @property {Object} mappings - data routing/mapping between different components using their IDs + * @typedef {object} Configuration + * @property {Component[]} components - configuration components + * @property {object} mappings - data routing/mapping between different components using their IDs * * { * mappings: { @@ -1302,13 +1376,28 @@ _module = module.exports = { * } */ /** - * @typedef ConsumerComponent - * @type {Component} + * @typedef {object} Connection + * @property {string} host - host + * @property {boolean} allowSelfSignedCert - allow self signed certs + * @property {number} port - port + * @property {'http' | 'https'} protocol - protocol + */ +/** + * @typedef {Component} ConsumerComponent * @property {string} type - consumer's type */ /** - * @typedef DataAction - * @type {object} + * @typedef {object} Credentials + * @property {string} username - username + * @property {Secret | string} passphrase - passphrase + */ +/** + * @typedef {object} CredentialsPartial + * @property {string} username - username + * @property {PassphraseSecret | string} [passphrase] - passphrase + */ +/** + * @typedef {object} DataAction * @property {boolean} enable - enable/disable */ /** @@ -1316,71 +1405,105 @@ _module = module.exports = { * @type {Array} */ /** - * @typedef ExcludeDataAction - * @type {DataAction} - * @property {object} includeData + * @typedef {DataAction} ExcludeDataAction + * @property {object} excludeData * @property {object} locations * @property {object} [ifAllMatch] * @property {object} [ifAnyMatch] */ /** - * @typedef IncludeDataAction - * @type {DataAction} + * @typedef {object} FilterOptions + * @param {StringFilter} [class] - class name(s) or function to use as filter + * @param {function(obj: Component): boolean} [filter] - function to use as filter + * @param {StringFilter} [id] - id(s) or function to use as filter + * @param {StringFilter} [name] - name(s) or function to use as filter + * @param {StringFilter} [namespace] - namespace name(s) or function to use as filter + */ +/** + * @typedef {AdditionalOption} HttpAgentOption + * @property {'keepAlive' | 'keepAliveMsecs' | 'maxSockets' | 'maxFreeSockets'} name - HTTP agent's option name + * @property {boolean | number} value + */ +/** + * @typedef {Component} IHealthPollerCompontent + * @property {object} iHealth - iHealth poller config + * @property {string} iHealth.name - poller's name + * @property {Credentials} iHealth.credentials - F5 iHealth Service credentials + * @property {string} iHealth.downloadFolder - Qkview download directory + * @property {object} iHealth.interval - polling interval + * @property {object} iHealth.interval.timeWindow - polli ng time window + * @property {string} iHealth.interval.start - start time + * @property {string} iHealth.interval.end - end time + * @property {string} iHealth.interval.frequency - polling frequency + * @property {number | string} iHealth.interval.day - day of week/month + * @property {Proxy} [iHealth.proxy] - proxy configuration + * @property {object} system - F5 Device config + * @property {Connection} system.connection - connectivity config + * @property {CredentialsPartial} [system.credentials] - credentials + * @property {string} system.name - system's name + */ +/** + * @typedef {DataAction} IncludeDataAction * @property {object} includeData * @property {object} locations * @property {object} [ifAllMatch] * @property {object} [ifAnyMatch] */ /** - * @typedef PassphraseSecret - * @type {object} - * @property {string} cipherText - encrypted secret - * @property {string} class - class name - * @property {string} protected - type of protection + * @typedef {object} Proxy + * @property {Connection} connection - connectivity config + * @property {string} connection.host - proxy host + * @property {ProxyCredentials} [credentials] - authentiction data */ /** - * @typedef PullConsumerSystemPollerGroup - * @type {Component} - * @property {Array} systemPollers - list of System Pollers IDs (enabled only) + * @typedef {CredentialsPartial} ProxyCredentials + * @property {string} username - username + * @property {Secret | string} [passphrase] - passphrase + */ +/** + * @typedef {Component} PullConsumerSystemPollerGroup + * @property {string[]} systemPollers - list of System Pollers IDs (enabled only) * @property {string} pullConsumer - pull consumer ID + * @property {string} pullConsumerName - pull consumer name (traceName) + */ +/** + * @typedef {object} Secret + * @property {string} cipherText - encrypted secret + * @property {string} class - class name + * @property {string} protected - type of protection */ /** - * @typedef SetTagDataAction - * @type {DataAction} + * @typedef {DataAction} SetTagDataAction * @property {object} setTag - tags to set * @property {object} [locations] * @property {object} [ifAllMatch] * @property {object} [ifAnyMatch] */ /** - * @typedef SystemPollerComponent - * @type {Component} - * @property {object} connection - connection config - * @property {boolean} connection.allowSelfSignedCert - * @property {string} connection.host - host - * @property {number} connection.port - port - * @property {string} connection.protocol - protocol (http or https) - * @property {object} credentials - auth config - * @property {string} credentials.username - * @property {PassphraseSecret | string} credentials.passphrase + * @typedef {(function(s: string): boolean) | string | string[]} StringFilter + */ +/** + * @typedef {Component} SystemPollerComponent + * @property {Connection} connection - connectivity config + * @property {CredentialsPartial} [credentials] - credentials * @property {object} dataOpts - data modifications config * @property {DataActions} dataOpts.actions * @property {boolean} dataOpts.noTMStats - if 'true' then exclude 'tmstats' from stats * @property {object} dataOpts.tags - old-style tagging * @property {Object} endpoints - endpoints to poll data from + * @property {HttpAgentOption[]} httpAgentOpts - additional options for HTTP agent * @property {number} interval - polling interval in seconds * @property {string} systemName - system's names it belongs to */ /** - * @typedef SystemPollerEndpoint - * @type {object} + * @typedef {object} SystemPollerEndpoint * @property {boolean} enable - enabled/disabled * @property {string} name - endpoint name * @property {string} path - endpoint path + * @property {'http' | 'snmp'} protocol - data retrieval protocol */ /** - * @typedef TraceConfig - * @type {object} + * @typedef {object} TraceConfig * @property {boolean} enable - enabled/disabled * @property {string} encoding - output encoding * @property {number} maxRecords - max records to store diff --git a/src/lib/utils/dacli.js b/src/lib/utils/dacli.js new file mode 100644 index 00000000..39babfd6 --- /dev/null +++ b/src/lib/utils/dacli.js @@ -0,0 +1,419 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-constant-condition */ + +const assert = require('./assert'); +const constants = require('../constants'); +const deviceUtil = require('./device'); +const miscUtil = require('./misc'); + +/** + * @module utils/dacli + * + * @typedef {import('./config').Connection} Connection + * @typedef {import('./config').Credentials} Credentials + */ + +const DACLI_RETRY_DELAY = 3000; +const DACLI_SCRIPT_URI = '/mgmt/tm/cli/script'; +const DACLI_SCRIPT_STDERR = '/dev/null'; +const DACLI_TASK_SCRIPT_URI = '/mgmt/tm/task/cli/script'; +const DACLI_SCRIPT_NAME = 'telemetry_delete_me__async_cli_cmd_script_runner'; +const DACLI_SCRIPT_CODE = 'proc script::run {} {\n set cmd [lreplace $tmsh::argv 0 0]; eval "exec $cmd 2> stderrfile"\n}'; + +/** + * F5 Device async CLI command execution via REST API. + * + * @property {Connection} connection - connection options + * @property {Credentials} credentials - auth options + * @property {string} host - host + * @property {Script} script - Script object to execute on the host + */ +class DeviceAsyncCLI { + /** + * @param {string} [host = constants.LOCAL_HOST] - host + * @param {object} [options] - options + * @param {Connection} [options.connection] - connection options + * @param {Credentials} [options.credentials] - auth options + * @param {null | string} [options.credentials.token] - authorization token + * @param {string} [options.folder = ''] - TMOS folder + * @param {string} [options.outputFile = DACLI_SCRIPT_STDERR] - path to file for stderr output + * @param {string} [options.partition = ''] - TMOS partition + * @param {string} [options.scriptName = DACLI_SCRIPT_NAME] - script name + */ + constructor() { + const host = (typeof arguments[0] === 'string' ? arguments[0] : undefined) || constants.LOCAL_HOST; + const options = (typeof arguments[0] === 'object' ? arguments[0] : arguments[1]) || {}; + + assert.oneOfAssertions( + () => assert.oneOfAssertions( + () => assert.not.exist(options.folder), + () => assert.empty(options.folder, 'folder') + ), + () => assert.allOfAssertions( + () => assert.string(options.partition, 'partition'), + () => assert.string(options.folder, 'folder') + ), + 'folder requires a partition to be defined' + ); + assert.bigip.credentials(host, options.credentials, 'credentials'); + + if (options.connection) { + assert.bigip.connection(options.connection, 'connection'); + } + + // TODO: does '/' need to be escaped/replaced? + const fullScriptName = [options.partition || '', options.folder || '', options.scriptName || DACLI_SCRIPT_NAME] + .filter((s) => s.length > 0); + + if (fullScriptName.length > 1) { + // nneds to add leading separator later + fullScriptName.splice(0, 0, ''); + } + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + connection: { + value: miscUtil.deepFreeze(miscUtil.deepCopy(options.connection || {})) + }, + credentials: { + value: miscUtil.deepCopy(options.credentials || {}) + }, + host: { + value: host + }, + script: { + value: miscUtil.deepFreeze({ + code: DACLI_SCRIPT_CODE.replace('stderrfile', options.outputFile || DACLI_SCRIPT_STDERR), + tmosName: fullScriptName.join('/'), + uriName: fullScriptName.join('~') + }) + } + }); + } + + /** + * @param {string} cmd - command to execute on the device + * @param {number} [retryDelay = DACLI_RETRY_DELAY] - task results polling delay + * + * @returns {void} once command execution completed + * @throws {Error} when failed to execute command + */ + async execute(cmd, retryDelay = DACLI_RETRY_DELAY) { + assert.string(cmd, 'cmd'); + assert.safeNumberGrEq(retryDelay, 0, 'retryDelay'); + + const errors = []; + let taskID = null; + + await auth.call(this); + + try { + await upsertTemporaryCLIscriptOnDevice.call(this, this.script); + taskID = await createTaskOnDevice.call(this, this.script, cmd); + await execTaskOnDevice.call(this, taskID); + await waitForAsyncTaskToFinishOnDevice.call(this, taskID, retryDelay); + } catch (err) { + errors.push(err); + } + + if (taskID !== null) { + try { + await removeTaskResultsFromDevice.call(this, taskID); + } catch (err) { + // ignore + } + try { + await removeTaskFromDevice.call(this, taskID); + } catch (err) { + // ignore + } + } + + try { + await removeTemporaryCLIscriptFromDevice.call(this, this.script); + } catch (err) { + // ignore + } + + if (errors.length > 0) { + errors[0].message = `DeviceAsyncCLI.execute: ${errors[0].message || errors[0]}`; + errors[0].errors = errors; + throw errors[0]; + } + } +} + +/** + * Adds `application/json` Content-Type header + * + * @param {object} options + * @param {object} [options.headers] + */ +function addJsonHeader(options) { + options.headers = Object.assign( + {}, + options.headers || {}, + { + 'Content-Type': 'application/json' + } + ); + return options; +} + +/** + * @this {DeviceAsyncCLI} + * + * @returns {void} once token successfully obtained + */ +async function auth() { + if (this.credentials.token) { + return; + } + // in case of optimization, replace with Object.assign + const options = miscUtil.deepCopy(this.connection); + const token = await deviceUtil.getAuthToken( + this.host, + this.credentials.username, + this.credentials.passphrase, + options + ); + this.credentials.token = token.token; + miscUtil.deepFreeze(this.credentials); +} + +/** + * Create a new task via REST API to execute the command via script + * + * @this {DeviceAsyncCLI} + * + * @param {Script} script - script object + * @param {string} cmd - command to execute on the device + * + * @returns {string} the task ID + * @throws {Error} when unable to create the task on the device + */ +async function createTaskOnDevice(script, cmd) { + const [body] = await request.call(this, DACLI_TASK_SCRIPT_URI, addJsonHeader({ + method: 'POST', + body: { + command: 'run', + name: script.tmosName, + utilCmdArgs: cmd + } + })); + if (typeof body === 'object' && typeof body._taskId !== 'undefined') { + return body._taskId; + } + throw new Error(`Failed to create a new task on the device: ${JSON.stringify(body)}`); +} + +/** + * @this {DeviceAsyncCLI} + * + * @param {Script} script - script object + * + * @returns {boolean} 'true' (when the script was created) or + * 'false' (when the script creation was failed) + */ +async function createTemporaryCLIscriptOnDevice(script) { + const [body, res] = await request.call(this, DACLI_SCRIPT_URI, addJsonHeader({ + method: 'POST', + body: { + name: script.tmosName, + apiAnonymous: script.code + } + })); + + const failCodes = [404, 409]; + return !((typeof body === 'object' && failCodes.includes(body.code)) || failCodes.includes(res.statusCode)); +} + +/** + * @this {DeviceAsyncCLI} + * + * @param {string} taskID - task ID to execute + * + * @returns {boolean} 'true' when task was executed + * @throws {Error} when unable to execute the task on the device + */ +async function execTaskOnDevice(taskID) { + const [body] = await request.call(this, `${DACLI_TASK_SCRIPT_URI}/${taskID}`, addJsonHeader({ + method: 'PUT', + body: { + _taskState: 'VALIDATING' + } + })); + if (typeof body === 'object') { + return true; + } + throw new Error(`Failed to execute the task on the device: ${JSON.stringify(body)}`); +} + +/** + * @this {DeviceAsyncCLI} + * + * @param {string} taskID - task ID to remove the task + * + * @returns {void} once the task was removed + */ +async function removeTaskFromDevice(taskID) { + await request.call(this, `${DACLI_TASK_SCRIPT_URI}/${taskID}`, { method: 'DELETE' }); +} + +/** + * @this {DeviceAsyncCLI} + * + * @param {string} taskID - task ID to remove the task's result + * + * @returns {void} once the task's result was removed + */ +async function removeTaskResultsFromDevice(taskID) { + await request.call(this, `${DACLI_TASK_SCRIPT_URI}/${taskID}/result`, { method: 'DELETE' }); +} + +/** + * @this {DeviceAsyncCLI} + * + * @param {Script} script - script object + * + * @returns {void} once the script was deleted + */ +async function removeTemporaryCLIscriptFromDevice(script) { + await request.call(this, `${DACLI_SCRIPT_URI}/${script.uriName}`, { method: 'DELETE' }); +} + +/** + * Send request to the device + * + * @this {DeviceAsyncCLI} + * + * @param {string} uri - URI to send request to + * @param {object} options - optional params for request + * @param {string} options.method - HTTP method + * @param {object} [options.body] - data to send + * + * @returns {any} response + */ +async function request(uri, options) { + Object.assign(options, miscUtil.deepCopy(this.connection)); + + options.credentials = { + username: this.credentials.username, + token: this.credentials.token + }; + options.continueOnErrorCode = true; + options.includeResponseObject = true; + + return deviceUtil.makeDeviceRequest(this.host, uri, options); +} + +/** + * Update the TMOS script object on the device + * + * @this {DeviceAsyncCLI} + * + * @param {Script} script - script object + * + * @returns {void} once the script was updated + */ +async function updateTemporaryCLIscriptOnDevice(script) { + const [body, res] = await request.call(this, `${DACLI_SCRIPT_URI}/${script.uriName}`, addJsonHeader({ + method: 'PUT', + body: { + name: script.tmosName, + apiAnonymous: script.code + } + })); + if (res.statusCode !== 200) { + throw new Error(`Failed to update the CLI script on device: ${JSON.stringify(body)}`); + } +} + +/** + * Configure temporary TMOS script object on the device + * + * @this {DeviceAsyncCLI} + * + * @param {Script} script - script object + * + * @returns {void} once the script was configured on the device + */ +async function upsertTemporaryCLIscriptOnDevice(script) { + if (!(await createTemporaryCLIscriptOnDevice.call(this, script))) { + await updateTemporaryCLIscriptOnDevice.call(this, script); + } +} + +/** + * Wait for completion of async task via REST API + * + * @this {DeviceAsyncCLI} + * + * @param {string} taskID - task ID to poll the task's status + * @param {integer} retryDelay - delay before attempt status check again + * + * @returns {object} the task's result data once the task was completed + * @throws {Error} when exeution failed + */ +async function waitForAsyncTaskToFinishOnDevice(taskID, retryDelay) { + while (true) { + const [body] = await request.call(this, `${DACLI_TASK_SCRIPT_URI}/${taskID}/result`, { method: 'GET' }); + if (typeof body === 'object' && body._taskState) { + if (body._taskState === 'FAILED') { + throw new Error(`Task failed unexpectedly: ${JSON.stringify(body)}`); + } else if (body._taskState === 'COMPLETED') { + return body; + } + } + await miscUtil.sleep(retryDelay); + } +} + +/** + * Execute command using Device Async CLI + * + * @param {string} cmd + * @param {number} [retryDelay = DACLI_RETRY_DELAY] - task results polling delay + * @param {string} [host = constants.LOCAL_HOST] - host + * @param {object} [options = {}] - options + * @param {Connection} [options.connection = {}] - connection options + * @param {Credentials} [options.credentials = {}] - auth options + * @param {string} [options.folder = ''] - TMOS folder + * @param {string} [options.outputFile = DACLI_SCRIPT_STDERR] - path to file for stderr output + * @param {string} [options.partition = ''] - TMOS partition + * @param {string} [options.scriptName = DACLI_SCRIPT_NAME] - script name + * + * @returns {void} once command execution completed + */ +module.exports = async function execute(cmd, ...constructorOpts) { + let retryDelay; + if (typeof constructorOpts[0] !== 'string' && typeof constructorOpts[0] !== 'object') { + retryDelay = constructorOpts[0]; + constructorOpts = constructorOpts.slice(1); + } + await (new DeviceAsyncCLI(...constructorOpts)).execute(cmd, retryDelay); +}; + +/** + * @typedef {object} Script + * @property {string} code - the script's code + * @property {string} tmosName - the script's TMOS name (/Common/folder/name or /Common/name or name) + * @property {string} uriName - the script's REST API name (~Common~folder~name or ~Common~name or name) + */ diff --git a/src/lib/utils/data.js b/src/lib/utils/data.js index cce2f5bb..6762dea5 100644 --- a/src/lib/utils/data.js +++ b/src/lib/utils/data.js @@ -137,7 +137,9 @@ function getMatches(data, property, propAsKey) { if (Object.prototype.hasOwnProperty.call(data, property)) { return [property]; } - const checkFx = propAsKey ? ((key) => property.match(key)) : ((key) => key.match(property)); + const matchFn = (d, pattern) => d.match(pattern === '*' ? '.*' : pattern); + const checkFx = propAsKey ? ((key) => matchFn(property, key)) : ((key) => matchFn(key, property)); + return Object.keys(data).filter(checkFx); } @@ -168,9 +170,6 @@ function getDeepMatches(data, matchObj) { matches.forEach((match) => { deepMatches = deepMatches.concat(getDeepMatches(data[match], matchObj[key])); }); - } else { - // No matches for the property key were found in the data - logger.verbose(`The data does not have anything that matches the property key ${key}`); } }); return deepMatches; diff --git a/src/lib/utils/device.js b/src/lib/utils/device.js index fc34006e..a8a75229 100644 --- a/src/lib/utils/device.js +++ b/src/lib/utils/device.js @@ -16,1110 +16,754 @@ 'use strict'; -const fs = require('fs'); -const crypto = require('crypto'); +/* eslint-disable no-constant-condition, no-continue */ +const assert = require('./assert'); const constants = require('../constants'); -const logger = require('../logger'); +const logger = require('../logger').getChild('utils').getChild('device'); const promiseUtil = require('./promise'); const requestsUtil = require('./requests'); const util = require('./misc'); +/** + * @module utils/device + * + * @typedef {import('./config').Connection} Connection + * @typedef {import('./config').Credentials} Credentials + */ + /** * Cache for info about the device TS is running on * + * @private + * * @property {String} TYPE - device's type - BIG-IP or Container * @property {Object} VERSION - version information - * @property {Boolean} RETRIEVE_SECRETS_FROM_TMSH - true when device is affected by bug and you should run - * TMSH command to retrieve secret (BZ745423) */ const HOST_DEVICE_CACHE = {}; const HDC_KEYS = { TYPE: 'TYPE', - VERSION: 'VERSION', - RETRIEVE_SECRETS_FROM_TMSH: 'RETRIEVE_SECRETS_FROM_TMSH' + VERSION: 'VERSION' }; /** - * F5 Device async CLI class definition starts here - */ -// define constants to avoid 'magic' numbers and strings -// DACLI - Device Async CLI -const DACLI_RETRY_DELAY = 3000; -const DACLI_SCRIPT_URI = '/mgmt/tm/cli/script'; -const DACLI_SCRIPT_STDERR = '/dev/null'; -const DACLI_TASK_SCRIPT_URI = '/mgmt/tm/task/cli/script'; -const DACLI_SCRIPT_NAME = 'telemetry_delete_me__async_cli_cmd_script_runner'; -const DACLI_SCRIPT_CODE = 'proc script::run {} {\n set cmd [lreplace $tmsh::argv 0 0]; eval "exec $cmd 2> stderrfile"\n}'; - -/** - * F5 Device async CLI command execution via REST API. - * Result returned by DeviceAsyncCLI.execute are not - * the actual result of command execution, it is object - * with task's info. - * - * @param {String} host - HTTP host, by default 'localhost' - * @param {Object} [options] - additional initialization options - * @param {String} [options.scriptName] - script name - * @param {String} [options.outputFile] - path to file for stderr - * @param {Object} [options.connection] - connection options - * @param {String} [options.connection.protocol] - host protocol to use, will override default protocol - * @param {Integer} [options.connection.port] - host's port to connect to, will override default port - * @param {String} [options.connection.allowSelfSignedCert] - false - requires SSL certificates be valid, - * true - allows self-signed certs - * @param {String} [options.credentials.username] - username for auth, will override default username - * @param {String} [options.credentials.passphrase] - passphrase for auth, will override default passphrase - * @param {String} [options.credentials.token] - auth token for re-use - * - * @property {String} host - HTTP host - * @property {String} options - see 'options' parameter - * @property {String} scriptName - script's name on the destination device. - * See DACLI_SCRIPT_NAME constant for default value - * @property {String} scriptCode - script's code on the destination device. - * See DACLI_SCRIPT_CODE constant for default value - * @property {String} partition - TMOS partition to use for script creation - * @property {String} subPath - TMOS subPath to use for script creation, requires partition - * @property {Integer} retryDelay - delay before attempt task's status check again. - * See DACLI_RETRY_DELAY constant for default value + * Adds `application/json` Content-Type header + * + * @param {object} options + * @param {object} [options.headers] */ -function DeviceAsyncCLI() { - // rest params syntax supported only from node 6+ - /* eslint-disable prefer-rest-params */ - this.host = typeof arguments[0] === 'string' ? arguments[0] : null; - this.host = this.host || constants.LOCAL_HOST; - - this.options = typeof arguments[0] === 'object' ? arguments[0] : arguments[1]; - this.options = this.options || {}; - // rely on makeDeviceRequest - this.options.credentials = this.options.credentials || {}; - this.options.connection = this.options.connection || {}; - - this.scriptName = this.options.scriptName || DACLI_SCRIPT_NAME; - this.scriptCode = DACLI_SCRIPT_CODE.replace(/stderrfile/, this.options.outputFile || DACLI_SCRIPT_STDERR); - this.partition = ''; - this.subPath = ''; - this.retryDelay = DACLI_RETRY_DELAY; +function addJsonHeader(options) { + options.headers = Object.assign( + {}, + options.headers || {}, + { + 'Content-Type': 'application/json' + } + ); } +/** HOST DEIVCE INFO START */ /** - * Execute command on the device + * Clear Host Device Info * * @public - * @param {String} cmd - command to execute on the device * - * @returns (Object} Promise resolved with execution results + * @param {...string} [key] - key(s) to remove, if absent then all keys will be removed */ -DeviceAsyncCLI.prototype.execute = function (cmd) { - // keep context - const script = { - name: this.scriptName, - code: this.scriptCode, - opts: { - partition: this.partition, - subPath: this.subPath - } - }; - const retryDelay = this.retryDelay; - const errors = []; - let taskID = null; - let taskResult = null; - - return this._auth() - .then(() => this._upsertTemporaryCLIscriptOnDevice(script)) - .then(() => this._createAsyncTaskOnDevice(script, cmd)) - .then((_taskID) => { - taskID = _taskID; - }) - .then(() => this._execAsyncTaskOnDevice(taskID)) - .then(() => this._waitForAsyncTaskToFinishOnDevice(taskID, retryDelay)) - .then((_taskResult) => { - taskResult = _taskResult; - }) - .catch((err) => { - errors.push(err.message); - }) - // try to remove results and task any way - .then(() => { - if (taskID) { - return this._removeAsyncTaskResultsFromDevice(taskID, taskResult === null) - .catch((err) => { - errors.push(err.message); - }) - .then(() => this._removeAsyncTaskFromDevice(taskID, taskID === null)) - .catch((err) => { - errors.push(err.message); - }); - } - return Promise.resolve(); - }) - // try to remove script in any case - .then(() => this._removeTemporaryCLIscriptFromDevice(script) - .catch((err) => { - errors.push(err.message); - })) - .then(() => { - if (errors.length) { - const err = new Error(`DeviceAsyncCLI.execute: ${JSON.stringify(errors)}`); - err.errors = errors; - return Promise.reject(err); - } - return Promise.resolve(taskResult); - }); -}; +function clearHostDeviceInfo(...keys) { + const keysToRemove = keys.length ? keys : Object.keys(HOST_DEVICE_CACHE); + keysToRemove.forEach((key) => { + assert.string(key, 'key'); + delete HOST_DEVICE_CACHE[key]; + }); +} /** - * Request auth token from the device + * Gather Host Device Info (Host Device is the device TS is running on) * - * @private - * @returns {Object} Promise resolved when token successfully obtained - */ -DeviceAsyncCLI.prototype._auth = function () { - if (this.options.credentials.token) { - return Promise.resolve(); - } - // in case of optimization, replace with Object.assign - const options = util.deepCopy(this.options.connection); - return module.exports.getAuthToken( - this.host, - this.options.credentials.username, - this.options.credentials.passphrase, - options - ) - .then((token) => { - this.options.credentials.token = token.token; - }); -}; - -/** - * Send request to the device + * Note: result of this operation will be cached * - * @private - * @param {String} uri - URI to send request to - * @param {Object} [options] - optional params for request - * @param {String} [options.method] - HTTP method - * @param {Object} [options.body] - data to send + * @public * - * @returns (Object} Promise resolved with execution results + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {void} once info about Host Device was gathered */ -DeviceAsyncCLI.prototype._request = function (uri, options) { - options = options || {}; - // remove parse-stringify in case of optimizations - Object.assign(options, util.deepCopy(this.options.connection)); - - options.credentials = { - username: this.options.credentials.username, - token: this.options.credentials.token - }; - options.continueOnErrorCode = true; - options.includeResponseObject = true; +async function gatherHostDeviceInfo(options = {}) { + const deviceType = await getDeviceType(false); + setHostDeviceInfo(HDC_KEYS.TYPE, deviceType); - return module.exports.makeDeviceRequest(this.host, uri, options); -}; + const deviceVersion = await getDeviceVersion(constants.LOCAL_HOST, options); + setHostDeviceInfo(HDC_KEYS.VERSION, deviceVersion); +} /** - * Configure temporary TMOS script object on the device + * Get Host Device info * - * @private - * @param {Object} script - script object - * @param {String} script.name - script's name - * @param {String} script.code - script's code - * @param {Object} [script.opts] - options - * @param {String} [script.opts.partition] - partition's name - * @param {String} [script.opts.subPath] - sub path - * - * @returns {Object} Promise resolved when script was configured on the device + * @public + * + * @param {string} [key] - key, if omitted then copy of cache will be returned + * + * @returns {any} value from cache for the key or copy of cache if no arguments were passed to function */ -DeviceAsyncCLI.prototype._upsertTemporaryCLIscriptOnDevice = function (script) { - return this._createTemporaryCLIscriptOnDevice(script) - .then((retVal) => (retVal && Promise.resolve()) || this._updateTemporaryCLIscriptOnDevice(script)); -}; +function getHostDeviceInfo(key) { + if (arguments.length === 0) { + return util.deepCopy(HOST_DEVICE_CACHE); + } + assert.string(key, 'key'); + return HOST_DEVICE_CACHE[key]; +} /** - * Create temporary TMOS script object on the device + * Set Host Device Info * * @private - * @param {Object} script - script object - * @param {String} script.name - script's name - * @param {String} script.code - script's code * - * @returns {Object} Promise resolved with 'true' (when script was created) or - * 'false' (when script creation was failed) + * @param {string} key - key + * @param {any} value - value */ -DeviceAsyncCLI.prototype._createTemporaryCLIscriptOnDevice = function (script) { - const args = { - name: script.name, - apiAnonymous: script.code - }; - return this._request(DACLI_SCRIPT_URI, { method: 'POST', body: args }) - .then((resp) => { - let retVal = true; - const body = resp[0]; - const respObj = resp[1]; - - if ((typeof body === 'object' && body.code && (body.code === 404 || body.code === 409)) - || (respObj.statusCode === 404 || respObj.statusCode === 409)) { - retVal = false; - } - return Promise.resolve(retVal); - }); -}; +function setHostDeviceInfo(key, value) { + assert.string(key, 'key'); + HOST_DEVICE_CACHE[key] = value; +} +/** HOST DEIVCE INFO END */ /** - * Update temporary TMOS script object on the device + * Decrypt secret * * @private - * @param {Object} script - script object - * @param {String} script.name - script's name - * @param {String} script.code - script's code - * @param {Object} [script.opts] - options - * @param {String} [script.opts.partition] - partition's name - * @param {String} [script.opts.subPath] - sub path - * - * @returns {Object} Promise resolved when script was updated + * + * @param {string} data - data to decrypt + * + * @returns {object} decrypted secret */ -DeviceAsyncCLI.prototype._updateTemporaryCLIscriptOnDevice = function (script) { - const name = module.exports.transformTMOSobjectName(script.opts.partition, script.name, script.opts.subPath); - const uri = `${DACLI_SCRIPT_URI}/${name}`; - const args = { - name: script.name, - apiAnonymous: script.code - }; +async function decryptSecret(secret) { + assert.string(secret, 'secret'); - return this._request(uri, { method: 'PUT', body: args }) - .then((resp) => { - if (resp[1].statusCode !== 200) { - return Promise.reject(new Error(`Failed to update temporary cli script on device: ${JSON.stringify(resp[0])}`)); - } - return Promise.resolve(); - }); -}; + secret = secret.split(','); + secret.forEach((s) => assert.string(s, 'sub-secret')); + + /** + * TODO: + * - check args type - ...args or [arg1, arg2] + * - restrict max length of data per call + */ + + return (await util.childProcess.execFile( + '/usr/bin/php', + [`${__dirname}/decryptConfValue.php`, ...secret] + )).stdout; +} /** - * Create async task via REST API to execute the command via script + * Decrypt all secrets * - * @private - * @param {Object} script - script object - * @param {String} script.name - script's name - * @param {String} cmd - command to execute on the device + * NOTE: mutates `data` * - * @returns {Object} Promise resolved with task ID - */ -DeviceAsyncCLI.prototype._createAsyncTaskOnDevice = function (script, cmd) { - const args = { - command: 'run', - name: script.name, - utilCmdArgs: cmd - }; - return this._request(DACLI_TASK_SCRIPT_URI, { method: 'POST', body: args }) - .then((resp) => { - const body = resp[0]; - if (typeof body === 'object' && body._taskId) { - return Promise.resolve(body._taskId); - } - return Promise.reject(new Error(`Failed to create the async task on the device: ${JSON.stringify(resp[0])}`)); - }); -}; - -/** - * Execute async task via REST API + * @public * - * @private - * @param {String} taskID - task ID to execute + * @param {object} data - data to decrypt * - * @returns {Object} Promise resolved with 'true' when task was executed + * @returns {object} decrypted data */ -DeviceAsyncCLI.prototype._execAsyncTaskOnDevice = function (taskID) { - const args = { - _taskState: 'VALIDATING' - }; - const uri = `${DACLI_TASK_SCRIPT_URI}/${taskID}`; - return this._request(uri, { method: 'PUT', body: args }) - .then((resp) => { - const body = resp[0]; - if (typeof body === 'object') { - return Promise.resolve(true); +async function decryptAllSecrets(data) { + const promises = []; + util.traverseJSON(data, (parent, key) => { + const item = parent[key]; + if (typeof item === 'object' && !Array.isArray(item) + && item !== null && item.class === constants.CONFIG_CLASSES.SECRET_CLASS) { + if (typeof item[constants.PASSPHRASE_CIPHER_TEXT] !== 'undefined') { + promises.push(decryptSecret(item[constants.PASSPHRASE_CIPHER_TEXT]) + .then((decryptedVal) => { + parent[key] = decryptedVal; + })); + } else if (typeof item[constants.PASSPHRASE_ENVIRONMENT_VAR] !== 'undefined') { + // constants.PASSPHRASE_ENVIRONMENT_VAR means secret resides in an environment variable + parent[key] = process.env[item[constants.PASSPHRASE_ENVIRONMENT_VAR]]; + if (typeof parent[key] === 'undefined') { + parent[key] = null; + logger.error(`Environment variable does not exist: ${item[constants.PASSPHRASE_ENVIRONMENT_VAR]}`); + } + } else { + parent[key] = null; } - return Promise.reject(new Error(`Failed to execute the async task on the device: ${JSON.stringify(resp[0])}`)); - }); -}; + // no needs to inspect nested data + return false; + } + return true; + }); + + const results = await promiseUtil.allSettled(promises); + promiseUtil.getValues(results); + return data; +} /** - * Wait for completion of async task via REST API + * Download file from the remote device. Function doesn't handle file removal * - * @private - * @param {String} taskID - task ID to poll the task's status - * @param {Integer} retryDelay - delay before attempt status check again + * NOTE: mutates `options` + * + * @public * - * @returns {Object} Promise resolved when task was completed + * @param {string | WritableStream} dst - destination, could be path to file or WriteableStream + * @param {string} host - host + * @param {string} uri - uri to download from the remote device + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {number} amount of downloaded data in bytes */ -DeviceAsyncCLI.prototype._waitForAsyncTaskToFinishOnDevice = function (taskID, retryDelay) { - const uri = `${DACLI_TASK_SCRIPT_URI}/${taskID}/result`; - const options = { method: 'GET' }; - const _this = this; - - return new Promise((resolve, reject) => { - function checkStatus() { - _this._request(uri, options).then((resp) => { - const body = resp[0]; - let retry = true; - if (typeof body === 'object' && body._taskState) { - if (body._taskState === 'FAILED') { - retry = false; - reject(new Error(`Task failed unexpectedly: ${JSON.stringify(body)}`)); - } else if (body._taskState === 'COMPLETED') { - retry = false; - resolve(body); +async function downloadFileFromDevice(dst, host, uri, options = {}) { + assert.oneOfAssertions( + () => assert.assert(typeof dst === 'object' && dst !== null, 'dst', 'should be a WritableStream'), + () => assert.string(dst, 'dst') + ); + + const chunkSize = constants.DEVICE_REST_API.CHUNK_SIZE; + const rangeRe = /^(\d+)-(\d+)\/(\d+)$/; + + let closeStream = false; + let currentBytes = 0; + let end = chunkSize - 1; + let totalSize = 0; + let streamError = null; + + options.method = 'GET'; + options.headers = options.headers || {}; + options.includeResponseObject = true; + options.continueOnErrorCode = true; + options.rawResponseBody = true; + + if (typeof dst === 'string') { + closeStream = true; + dst = util.fs.createWriteStream(dst, { flags: 'w' }); + } + let wsOpened = false; + + // add our own listeners + function wsErrorHandler(err) { + dst.removeListener('error', wsErrorHandler); + streamError = err; + } + function wsOpenHandler() { + dst.removeListener('open', wsOpenHandler); + wsOpened = true; + } + dst.on('open', wsOpenHandler); + dst.on('error', wsErrorHandler); + + const writeData = (data) => new Promise((resolve, reject) => { + if (streamError) { + reject(streamError); + } else { + try { + dst.write(data, (err) => { + if (err) { + reject(err); + } else { + currentBytes += data.length; + resolve(); } - } - if (retry) { - setTimeout(checkStatus, retryDelay); - } - }); + }); + } catch (err) { + reject(err); + } } - checkStatus(); }); -}; + + const download = async () => { + while (true) { + end = ( + (currentBytes + chunkSize) > totalSize + ? (totalSize || chunkSize) + : (currentBytes + chunkSize) + ) - 1; + + const headers = { + 'Content-Range': `${currentBytes}-${end}/${totalSize}`, + 'Content-Type': 'application/octet-stream' + }; + const optionsCopy = util.deepCopy(options); + Object.assign(optionsCopy.headers, headers); + + const [respBody, respObj] = await makeDeviceRequest(host, uri, optionsCopy); + const crange = (respObj.headers['content-range'] || '').match(rangeRe); + + if (respObj.statusCode !== 200 || !crange) { + const msg = respObj.statusCode === 200 ? '(invalid Content-Range header)' : ''; + throw new Error(`downloadFileFromDevice: HTTP Error: ${respObj.statusCode} ${respObj.statusMessage}${msg}`); + } + + if (totalSize === 0) { + totalSize = parseInt(crange[3], 10); + assert.safeNumberGrEq(totalSize, 0, 'downloadFileFromDevice: totalSize'); + + if (totalSize === 0) { + // no data at all? + break; + } + } + + const rangeStart = parseInt(crange[1], 10); + assert.safeNumberEq(rangeStart, currentBytes, 'downloadFileFromDevice: rangeStart'); + + const rangeEnd = parseInt(crange[2], 10); + assert.safeNumberGrEq(rangeEnd, 0, 'downloadFileFromDevice: rangeEnd'); + + const dataSize = rangeEnd - rangeStart + 1; + assert.safeNumberGr(dataSize, 0, 'downloadFileFromDevice: rangeSize'); + assert.safeNumberEq(respBody.length, dataSize, 'downloadFileFromDevice: rangeSize'); + + assert.safeNumberLsEq(currentBytes + dataSize, totalSize, '`downloadFileFromDevice: the size of downloaded data'); + + await writeData(respBody); + + if (currentBytes === totalSize) { + break; + } + } + }; + + try { + await download(); + } catch (downloadError) { + streamError = streamError || downloadError; + } + + if (closeStream && dst.end && wsOpened) { + // have to close the stream opened at the start + await (new Promise((resolve) => { + dst.on('close', () => resolve()); + dst.end(() => { + dst.close(); + }); + })); + } + + if (streamError) { + throw streamError; + } + + return currentBytes; +} /** - * Remove the script from the device + * Encrypt secret * - * @private - * @param {Object} script - script object - * @param {String} script.name - script's name - * @param {Object} [script.opts] - options - * @param {String} [script.opts.partition] - partition's name - * @param {String} [script.opts.subPath] - sub path + * @public + * + * @param {string} secret - data to encrypt + * @param {object} [options] - function options, see 'makeDeviceRequest' * - * @returns {Object} Promise resolved when the script was deleted + * @returns {string} encrypted secret */ -DeviceAsyncCLI.prototype._removeTemporaryCLIscriptFromDevice = function (script) { - const name = module.exports.transformTMOSobjectName(script.opts.partition, script.name, script.opts.subPath); - const uri = `${DACLI_SCRIPT_URI}/${name}`; - - return this._request(uri, { method: 'DELETE' }) - .then((resp) => { - if (resp[1].statusCode !== 200) { - return Promise.reject(new Error(`Failed to remove the temporary cli script from the device: ${JSON.stringify(resp[0])}`)); +async function encryptSecret(secret, options = {}) { + assert.string(secret, 'secret'); + + /** + * TODO: + * - move max chunk size to constants + * - length restrictions + * - original size is way less than encrypted size + * - if we do b64 for original data then encrypted is way way bigger + * - fix BZ1292457 + */ + secret = secret.match(/(.|\n){1,500}/g); + + const result = []; + for (let i = 0; i < secret.length; i += 1) { + let encryptError = null; + let response; + const radiusObjectName = `telemetry_delete_me_${util.generateUuid().slice(0, 6)}`; + const uri = '/mgmt/tm/ltm/auth/radius-server'; + + const httpPostOptions = Object.assign({}, options, { + method: 'POST', + body: { + name: radiusObjectName, + secret: secret[i], + server: 'foo' } - return Promise.resolve(); }); -}; + addJsonHeader(httpPostOptions); + + const httpDeleteOptions = Object.assign({}, options, { + method: 'DELETE', + continueOnErrorCode: true // ignore error to avoid UnhandledPromiseRejection error + }); + + try { + response = await makeDeviceRequest(constants.LOCAL_HOST, uri, httpPostOptions); + } catch (error) { + encryptError = error; + } + try { + // remove TMOS object at first to keep BIG-IP clean and then throw error if needed + await makeDeviceRequest(constants.LOCAL_HOST, `${uri}/${radiusObjectName}`, httpDeleteOptions); + } catch (error) { + // do nothing + } + + if (!encryptError) { + if (typeof response.secret !== 'string') { + // well this can't be good + encryptError = new Error(`encryptSecret: Secret could not be retrieved: ${util.stringify(response)}`); + } else if (response.secret.includes(',')) { + encryptError = new Error('encryptSecret: Encrypted data should not have a comma in it'); + } + } + if (encryptError) { + throw encryptError; + } + result.push(response.secret); + } + + return result.join(','); +} /** - * Remove the task's result from the device + * Execute shell command(s) via REST API on BIG-IP. + * Command should have escaped quotes. + * If host is not localhost then auth token should be passed along with headers. * - * @private - * @param {String} taskID - task ID to remove the task's result - * @param {Boolean} errOk - ignore error + * NOTE: mutates `options` * - * @returns {Object} Promise resolved when the task's result was deleted + * @public + * + * @param {string} host - host + * @param {string} command - shell command + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {string} command's output */ -DeviceAsyncCLI.prototype._removeAsyncTaskResultsFromDevice = function (taskID, errOk) { - const uri = `${DACLI_TASK_SCRIPT_URI}/${taskID}/result`; - return this._request(uri, { method: 'DELETE' }) - .then((resp) => { - if (resp[1].statusCode !== 200 && !errOk) { - return Promise.reject(new Error(`Failed to delete the async task results from the device: ${JSON.stringify(resp[0])}`)); - } - return Promise.resolve(); - }); -}; +async function executeShellCommandOnDevice(host, command, options = {}) { + assert.string(command, 'command'); + + const uri = '/mgmt/tm/util/bash'; + addJsonHeader(options); + options.method = 'POST'; + options.includeResponseObject = false; + options.body = { + command: 'run', + utilCmdArgs: `-c "${command}"` + }; + return (await makeDeviceRequest(host, uri, options)).commandResult || ''; +} /** - * Remove the task from the device + * Get auth token * - * @private - * @param {String} taskID - task ID to remove the task - * @param {Boolean} errOk - ignore error + * NOTE: mutates `options` * - * @returns {Object} Promise resolved when the task was deleted - */ -DeviceAsyncCLI.prototype._removeAsyncTaskFromDevice = function (taskID, errOk) { - const uri = `${DACLI_TASK_SCRIPT_URI}/${taskID}`; - return this._request(uri, { method: 'DELETE' }) - .then((resp) => { - if (resp[1].statusCode !== 200 && !errOk) { - return Promise.reject(new Error(`Failed to delete the async task from the device: ${JSON.stringify(resp[0])}`)); - } - return Promise.resolve(); - }); -}; -/** - * F5 Device async CLI class definition ends here + * @param {string} host - host + * @param {string} username - device username + * @param {string} passphrase - device passphrase + * @param {Connection} [options] - function options + * + * @returns {{ token: string }} auth token (for localhost access `token` property set to null intentionally) */ +async function getAuthToken(host, username, passphrase, options = {}) { + let token = { token: null }; + // if host is localhost we do not need an auth token + if (host !== constants.LOCAL_HOST) { + token = await requestAuthToken.call(this, host, username, passphrase, options); + } + return token; +} /** - * Helper function for the encryptSecret function + * Fetch device's info * - * @private - * @param {Array} splitData - the secret that has been split up - * @param {Array} dataArray - the array that the encrypted data will be put - * @param {Integer} index - the index value used to go through the split data - * @param {Boolean} secretsFromTMSH - fetch secrets from TMSH + * Fetches system info for arbitrary BIG-IP via REST API + * + * NOTE: mutates `options` + * + * @public * - * @returns {Promise} Promise resolved with the encrypted data + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {object} device's info */ -function encryptSecretHelper(splitData, dataArray, index, secretsFromTMSH) { - let encryptedData = null; - let error = null; - // can't have a + or / in the radius object name, so replace those if they exist - const radiusObjectName = `telemetry_delete_me_${crypto.randomBytes(6) - .toString('base64') - .replace(/[+]/g, '-') - .replace(/\x2f/g, '_')}`; - const uri = '/mgmt/tm/ltm/auth/radius-server'; - const httpPostOptions = { - method: 'POST', - body: { - name: radiusObjectName, - secret: splitData[index], - server: 'foo' - } +async function getDeviceInfo(host, options = {}) { + const uri = '/mgmt/shared/identified-devices/config/device-info'; + options.method = 'GET'; + options.includeResponseObject = false; + + const res = await makeDeviceRequest(host, uri, options); + return { + baseMac: res.baseMac, + build: res.build, + chassisSerialNumber: res.chassisSerialNumber, + halUuid: res.halUuid, + hostMac: res.hostMac, + hostname: res.hostname, + isClustered: res.isClustered, + isVirtual: res.isVirtual, + machineId: res.machineId, + managementAddress: res.managementAddress, + mcpDeviceName: res.mcpDeviceName, + physicalMemory: res.physicalMemory, + platform: res.platform, + product: res.product, + trustDomainGuid: res.trustDomainGuid, + version: res.version }; - - return module.exports.makeDeviceRequest(constants.LOCAL_HOST, uri, httpPostOptions) - .then((res) => { - if (typeof res.secret !== 'string') { - // well this can't be good - logger.error(`Secret could not be retrieved: ${util.stringify(res)}`); - } - // update text field with Secure Vault cryptogram - should we base64 encode? - encryptedData = res.secret; - - if (!secretsFromTMSH) { - return Promise.resolve(); - } - - // TMOS 14.1.x fix for 745423 - const tmshCmd = `tmsh -a list auth radius-server ${radiusObjectName} secret`; - return module.exports.executeShellCommandOnDevice(constants.LOCAL_HOST, tmshCmd) - .then((tmosOutput) => { - /** - * auth radius-server telemetry_delete_me { - * secret - * } - */ - encryptedData = tmosOutput.split('\n')[1].trim().split(' ', 2)[1]; - }); - }) - .catch((e) => { - error = e; - }) - .then(() => { - // remove TMOS object at first to keep BIG-IP clean and then throw error if needed - const httpDeleteOptions = { - method: 'DELETE', - continueOnErrorCode: true // ignore error to avoid UnhandledPromiseRejection error - }; - module.exports.makeDeviceRequest(constants.LOCAL_HOST, `${uri}/${radiusObjectName}`, httpDeleteOptions); - if (error) { - throw error; - } - if (encryptedData.indexOf(',') !== -1) { - throw new Error('Encrypted data should not have a comma in it'); - } - dataArray.push(encryptedData); - index += 1; - if (index < splitData.length) { - return encryptSecretHelper(splitData, dataArray, index, secretsFromTMSH); - } - return Promise.resolve(dataArray); - }); } /** - * Check if TMOS version affected by bug when secrets should be fetched from TMSH only (BZ745423) + * Performs a check of the local environment and returns type of the device + * + * @public * - * @param {Object} version - TMOS version info - * @param {String} version.version - TMOS version string + * @property {boolean} [cached = true] - use cached data if available * - * @returns {Boolean} true if TMOS version affected by bug + * @returns {constants.DEVICE_TYPE.BIG_IP | constants.DEVICE_TYPE.CONTAINER} type of the device */ -function isVersionAffectedBySecretsBug(version) { - return util.compareVersionStrings(version.version, '>=', '14.1') - && util.compareVersionStrings(version.version, '<', '15.0'); -} - -module.exports = { - /** - * Gather Host Device Info (Host Device is the device TS is running on) - * - * Note: result of this operation will be cached - * - * @returns {Promise} resolved once info about Host Device was gathered - */ - gatherHostDeviceInfo() { - return this.getDeviceType() - .then((deviceType) => { - this.setHostDeviceInfo(HDC_KEYS.TYPE, deviceType); - return this.getDeviceVersion(constants.LOCAL_HOST); - }) - .then((deviceVersion) => { - this.setHostDeviceInfo(HDC_KEYS.VERSION, deviceVersion); - this.setHostDeviceInfo( - HDC_KEYS.RETRIEVE_SECRETS_FROM_TMSH, - isVersionAffectedBySecretsBug(deviceVersion) - ); - }); - }, - - /** - * Clear Host Device Info - * - * @param {...String} [key] - key(s) to remove, if absent then all keys will be removed - */ - clearHostDeviceInfo() { - const keysToRemove = arguments.length ? arguments : Object.keys(HOST_DEVICE_CACHE); - Array.prototype.forEach.call(keysToRemove, (toRemove) => { - delete HOST_DEVICE_CACHE[toRemove]; - }); - }, - - /** - * Get Host Device info - * - * @param {String} [key] - key, if omitted then copy of cache will be returned - * - * @returns {Object|Any} value from cache for the key or copy of cache if no arguments were passed to function - */ - getHostDeviceInfo(key) { - if (arguments.length === 0) { - return util.deepCopy(HOST_DEVICE_CACHE); - } - return HOST_DEVICE_CACHE[key]; - }, - - /** - * Set Host Device Info - * @param {String} key - key - * @param {Any} value - value - */ - setHostDeviceInfo(key, value) { - HOST_DEVICE_CACHE[key] = value; - }, - - /** - * Performs a check of the local environment and returns device type - * - * @returns {Promise} A promise which is resolved with the device type. - */ - getDeviceType() { - const deviceType = this.getHostDeviceInfo(HDC_KEYS.TYPE); - if (typeof deviceType !== 'undefined') { - return Promise.resolve(deviceType); - } - return util.fs.readFile('/VERSION') - .then((ret) => { - if (/product:\s+big-ip/i.test(ret[0].toString())) { - return Promise.resolve(constants.DEVICE_TYPE.BIG_IP); - } - return Promise.reject(new Error('Host is not BIG-IP')); - }) - .catch((readErr) => { - logger.debugException('Unable to detect device type', readErr); - return Promise.resolve(constants.DEVICE_TYPE.CONTAINER); - }); - }, +async function getDeviceType(cached = true) { + // check cached value first + const deviceType = getHostDeviceInfo(HDC_KEYS.TYPE); + if (cached && typeof deviceType !== 'undefined') { + return deviceType; + } - /** - * Download file from the remote device. Function doesn't handle file removal - * - * @param {String | WritableStream} dst - destination, could be path to file or WriteableStream - * @param {String} host - host - * @param {String} uri - uri to download from the remote device - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * - * @returns {Promise} resolved once file downloaded - */ - downloadFileFromDevice(dst, host, uri, options) { - const _this = this; - const chunkSize = 512 * 1024; - const attemptsOnHTTPerror = 5; - - let attempt = 0; - let start = 0; - let end = chunkSize - 1; - let size = 0; - let currentBytes = 0; - let closeStream = false; - - options = options || {}; - options.method = 'GET'; - options.headers = options.headers || {}; - options.includeResponseObject = true; - options.continueOnErrorCode = true; - options.rawResponseBody = true; - - if (typeof dst === 'string') { - closeStream = true; - dst = fs.createWriteStream(dst, { flags: 'w' }); - } - let error; - let wsOpened = false; + let data = ''; + try { + data = (await util.fs.readFile('/VERSION')).toString(); + } catch (readError) { + logger.debugException('getDeviceType: Unable to read /VERSION to detect type of the device', readError); + } - // add our own listeners - function wsErrorHandler(err) { - dst.removeListener('error', wsErrorHandler); - error = err; - } - function wsOpenHandler() { - dst.removeListener('open', wsOpenHandler); - wsOpened = true; - } - dst.on('open', wsOpenHandler); - dst.on('error', wsErrorHandler); + return /product:\s+big-ip/i.test(data) ? constants.DEVICE_TYPE.BIG_IP : constants.DEVICE_TYPE.CONTAINER; +} - function _download() { - const headers = { - 'Content-Range': `${start}-${end}/${chunkSize}`, - 'Content-Type': 'application/octet-stream' - }; - const optionsCopy = util.deepCopy(options); - optionsCopy.body = { - headers: Object.assign(optionsCopy.headers, headers), - verify: false, - stream: false - }; +/** + * Returns installed software version + * + * NOTE: mutates `options` + * + * @public + * + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {object} software version info + */ +async function getDeviceVersion(host, options = {}) { + const uri = '/mgmt/tm/sys/version'; + options.method = 'GET'; + options.includeResponseObject = false; - return _this.makeDeviceRequest(host, uri, optionsCopy) - .then((res) => { - if (error) { - return Promise.reject(error); - } + const res = await makeDeviceRequest(host, uri, options); + const entries = res.entries[Object.keys(res.entries)[0]].nestedStats.entries; - let promise; - const respBody = res[0]; - const respObj = res[1]; - const crange = respObj.headers['content-range']; + const result = {}; + Object.entries(entries).forEach(([key, value]) => { + result[key[0].toLowerCase() + key.slice(1)] = value.description; + }); - // should have content-range header - if (!crange) { - const msg = `${respObj.statusCode} ${respObj.statusMessage} ${JSON.stringify(respBody)}`; - return Promise.reject(new Error(`HTTP Error: ${msg}`)); - } - if (respObj.statusCode >= 200 && respObj.statusCode < 300) { - // handle it in async way, waiting for callback from write - promise = new Promise((resolve, reject) => { - currentBytes += parseInt(respObj.headers['content-length'], 10); - dst.write(respBody, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } else { - attempt += 1; - if (attempt >= attemptsOnHTTPerror) { - return Promise.reject(new Error('Exceeded number of attempts on HTTP error')); - } - } + return result; +} - promise = promise || Promise.resolve(); - return promise.then(() => { - if (size === 0) { - size = parseInt(crange.split('/').slice(-1)[0], 10); - } - start = currentBytes; - end = (currentBytes + chunkSize) > size ? size : (start + chunkSize); - // data starts from 0 position - end -= 1; - if (start > size) { - error = new Error(`Exceeded expected size: ${start} bytes read, expected ${size} bytes`); - } - return start === size ? Promise.resolve() : _download(); - }); - }); - } +/** + * Checks `shell` availability + * + * NOTE: mutates `options` + * + * @public + * + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {boolean} true if `shell` enabled + */ +async function isShellEnabled(host, options = {}) { + const uri = '/mgmt/tm/sys/db/systemauth.disablebash'; + options.method = 'GET'; + options.includeResponseObject = false; - return _download() - .catch((err) => { - error = err; - }) - .then(() => { - let promise; - if (closeStream && dst.end) { - // it is our own stream - promise = new Promise((resolve) => { - dst.on('close', () => resolve()); - dst.end(() => { - dst.close(); - if (!wsOpened) { - resolve(); - } - }); - if (!wsOpened) { - // multiple 'resolve' possible but 'promise' will be fulfilled already. - resolve(); - } - }); - } - promise = promise || Promise.resolve(); - if (error) { - promise = promise.then(() => Promise.reject(new Error(`downloadFileFromDevice: ${error}`))); - } - return promise; - }); - }, + const res = await makeDeviceRequest(host, uri, options); + return typeof res === 'object' && res.value === 'false'; +} - /** - * Run Unix Command (ls/mv/rm) via REST API - * - * @param {String} cmd - command to run (ls/mv/rm) - * @param {String} args - arguments to pass to command - * @param {String} host - host - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * @param {Boolean} [options.splitLsOutput = true] - split 'ls' output into array - * - * @returns {Promise>} resolved with command's output - */ - runTMUtilUnixCommand(cmd, args, host, options) { - if (['ls', 'rm', 'mv'].indexOf(cmd) === -1) { - throw new Error(`runTMUtilUnixCommand: invalid command '${cmd}'`); - } - const uri = `/mgmt/tm/util/unix-${cmd}`; - options = options || {}; - options.method = 'POST'; - options.includeResponseObject = false; - options.body = { - command: 'run', - utilCmdArgs: args - }; - const splitLsOutput = typeof options.splitLsOutput === 'undefined' || options.splitLsOutput; - delete options.splitLsOutput; - - return this.makeDeviceRequest(host, uri, options) - .then((res) => { - // mv and rm should have no commandResult on success - if (res.commandResult && ( - (cmd === 'ls' && res.commandResult.startsWith('/bin/ls:')) - || cmd === 'mv' || cmd === 'rm')) { - return Promise.reject(new Error(res.commandResult)); - } - res.commandResult = res.commandResult || ''; - if (cmd === 'ls' && splitLsOutput) { - res.commandResult = res.commandResult.split(/\r\n|\r|\n/); - if (res.commandResult[res.commandResult.length - 1] === '') { - res.commandResult.pop(); - } - } - return Promise.resolve(res.commandResult); - }); - }, +/** + * Send request to the device + * + * NOTE: mutates `options` + * + * @public + * + * @param {string} host - host + * @param {string} uri - uri + * @param {Connection} [options] - function options, similar to 'makeRequest'. Copy it before pass to function. + * @param {Credentials} [options.credentials] - authorization data + * @param {null | string} [options.credentials.token] - authorization token + * @param {boolean} [otions.noAuthHeader] - do not include auth data into request headers + * @param {boolean} [options.rawResponseBody] - return response as Buffer object with binary data + * + * @returns {any} response + */ +async function makeDeviceRequest(host, uri, options = {}) { + assert.string(host, 'host'); + assert.string(uri, 'uri'); + assert.assert(typeof options === 'object' && options, 'options should be an object'); - /** - * Returns installed software version - * - * @param {String} host - HTTP host - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * - * @returns {Promise} A promise which is resolved with response - * - */ - getDeviceVersion(host, options) { - const uri = '/mgmt/tm/sys/version'; - options = options || {}; - options.method = 'GET'; - options.includeResponseObject = false; - - return this.makeDeviceRequest(host, uri, options) - .then((res) => { - const entries = res.entries[Object.keys(res.entries)[0]].nestedStats.entries; - const result = {}; - Object.keys(entries).forEach((prop) => { - result[prop[0].toLowerCase() + prop.slice(1)] = entries[prop].description; - }); - return result; - }); - }, + options.headers = options.headers || {}; + const headers = options.headers; - /** - * Send request to the device - * - * @param {String} host - HTTP host - * @param {String} uri - HTTP uri - * @param {Object} [options] - function options, similar to 'makeRequest'. - * Copy it before pass to function. - * @param {Object} [options.credentials] - authorization data - * @param {String} [options.credentials.username] - username for authorization. Ignored when 'token' specified - * @param {String} [options.credentials.token] - authorization token - * @param {Boolean} [options.rawResponseBody] - return response as Buffer object with binary data - * - * @returns {Object} Returns promise resolved with response - */ - makeDeviceRequest(host, uri, options) { - options = options || {}; - options.headers = options.headers || {}; - const headers = options.headers; + if (options.noAuthHeader !== true) { const credentials = options.credentials || {}; - delete options.credentials; - - if (!headers['x-f5-auth-token']) { - if (credentials.token) { - headers['x-f5-auth-token'] = credentials.token; - } else if (!headers.Authorization) { - const username = credentials.username || constants.DEVICE_REST_API.USER; - headers.Authorization = `Basic ${Buffer.from(`${username}:`).toString('base64')}`; - } - } // else - should we delete 'Authorization' header? + if (credentials.token) { + headers['x-f5-auth-token'] = credentials.token; + } else { + // try passwordless auth (for local access only) + const username = credentials.username || constants.DEVICE_REST_API.USER; + headers.Authorization = `Basic ${Buffer.from(`${username}:`).toString('base64')}`; + } + } - options.protocol = options.protocol || constants.DEVICE_REST_API.PROTOCOL; - options.port = options.port || constants.DEVICE_REST_API.PORT; - return requestsUtil.makeRequest(host, uri, options); - }, + if (typeof options.allowSelfSignedCert === 'undefined') { + options.allowSelfSignedCert = !constants.STRICT_TLS_REQUIRED; + } + if (typeof options.port === 'undefined') { + options.port = constants.DEVICE_REST_API.PORT; + } + if (typeof options.protocol === 'undefined') { + options.protocol = constants.DEVICE_REST_API.PROTOCOL; + } - /** - * Execute shell command(s) via REST API on BIG-IP. - * Command should have escaped quotes. - * If host is not localhost then auth token should be passed along with headers. - * - * @param {String} host - HTTP host - * @param {String} command - shell command - * @param {Object} options - function options, see 'makeDeviceRequest' - * - * @returns {Object} Returns promise resolved with response - */ - executeShellCommandOnDevice(host, command, options) { - const uri = '/mgmt/tm/util/bash'; - options = options || {}; - options.method = 'POST'; - options.includeResponseObject = false; - options.body = { - command: 'run', - utilCmdArgs: `-c "${command}"` - }; - return this.makeDeviceRequest(host, uri, options) - .then((res) => res.commandResult || ''); - }, + assert.bigip.connection(options, 'options'); + delete options.credentials; - /** - * Request auth token - * - * @param {String} host - HTTP host - * @param {String} username - device username - * @param {String} password - device password - * @param {Object} [options] - function options - * @param {String} [options.protocol] - HTTP protocol - * @param {Integer} [options.port] - HTTP port - * @param {Boolean} [options.allowSelfSignedCert] - false - requires SSL certificates be valid, - * true - allows self-signed certs - * - * @returns {Object} Returns promise resolved with auth token: { token: 'token' } - */ - requestAuthToken(host, username, password, options) { - const uri = '/mgmt/shared/authn/login'; - options = options || {}; - options.method = 'POST'; - options.includeResponseObject = false; - options.body = { - username, - password, - loginProviderName: 'tmos' - }; - - return this.makeDeviceRequest(host, uri, options) - .then((data) => ({ token: data.token.token })); - }, + return requestsUtil.makeRequest(host, uri, options); +} - /** - * Get auth token - * - * @param {String} host - HTTP host - * @param {String} username - device username - * @param {String} password - device password - * @param {Object} [options] - function options - * @param {String} [options.protocol] - HTTP protocol - * @param {Integer} [options.port] - HTTP port - * @param {Boolean} [options.allowSelfSignedCert] - false - requires SSL certificates be valid, - * true - allows self-signed certs - * - * @returns {Object} Returns promise resolved with auth token: { token: 'token' } - */ - getAuthToken(host, username, password, options) { - let promise; - // if host is localhost we do not need an auth token - if (host === constants.LOCAL_HOST) { - promise = Promise.resolve({ token: null }); - } else { - if (!(username && password)) { - return Promise.reject(new Error('getAuthToken: Username and password required')); - } - promise = this.requestAuthToken(host, username, password, options); - } - return promise; - }, +/** + * Check if path exists on the device + * + * NOTE: mutates `options` + * + * @public + * + * @param {string} path - path + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest + * @param {boolean} [options.splitLines = true] - split output into array + * + * @returns {string | string[]} array of file names + */ +async function pathExists(path, host, { splitLines = true } = {}) { + const options = arguments[2] || {}; + delete options.splitLines; - /** - * Encrypt secret - * - * @param {String} data - data to encrypt - * - * @returns {Object} Returns promise resolved with encrypted secret - */ - encryptSecret(data) { - let affectedByBug = this.getHostDeviceInfo(HDC_KEYS.RETRIEVE_SECRETS_FROM_TMSH); - let promise = Promise.resolve(); - - if (typeof affectedByBug === 'undefined') { - promise = promise.then(() => this.getDeviceVersion(constants.LOCAL_HOST)) - .then((deviceVersion) => { - affectedByBug = isVersionAffectedBySecretsBug(deviceVersion); - }); - } - return promise.then(() => { - const splitData = data.match(/(.|\n){1,500}/g); - return encryptSecretHelper(splitData, [], 0, affectedByBug).then((result) => result.join(',')); - }); - }, + const res = await runTMUtilUnixCommand.call(this, 'ls', `"${path}"`, host, options); + if (res.commandResult && res.commandResult.includes('ls:')) { + throw new Error(`pathExists: ${res.commandResult}`); + } - /** - * Decrypt secret - * - * @param {String} data - data to decrypt - * - * @returns {Object} Returns promise resolved with decrypted secret - */ - decryptSecret(data) { - const splitData = data.split(','); - const args = [`${__dirname}/decryptConfValue.php`].concat(splitData); - return util.childProcess.execFile('/usr/bin/php', args) - .then((ret) => ret[0]); - }, + res.commandResult = res.commandResult || ''; + if (options && splitLines) { + res.commandResult = res.commandResult.split(/\r\n|\r|\n/).filter((s) => s); + } + return res.commandResult; +} - /** - * Decrypt all secrets - * - * @param {Object} data - data to decrypt - * - * @returns {Promise} resolve with decrypted data - */ - decryptAllSecrets(data) { - const promises = []; - util.traverseJSON(data, (parent, key) => { - const item = parent[key]; - if (typeof item === 'object' && !Array.isArray(item) - && item !== null && item.class === constants.CONFIG_CLASSES.SECRET_CLASS) { - if (typeof item[constants.PASSPHRASE_CIPHER_TEXT] !== 'undefined') { - promises.push(this.decryptSecret(item[constants.PASSPHRASE_CIPHER_TEXT]) - .then((decryptedVal) => { - parent[key] = decryptedVal; - })); - } else if (typeof item[constants.PASSPHRASE_ENVIRONMENT_VAR] !== 'undefined') { - // constants.PASSPHRASE_ENVIRONMENT_VAR means secret resides in an environment variable - parent[key] = process.env[item[constants.PASSPHRASE_ENVIRONMENT_VAR]]; - if (typeof parent[key] === 'undefined') { - parent[key] = null; - logger.error(`Environment variable does not exist: ${item[constants.PASSPHRASE_ENVIRONMENT_VAR]}`); - } - } else { - parent[key] = null; - } - // no needs to inspect nested data - return false; - } - return true; - }); - return promiseUtil.allSettled(promises) - .then((results) => { - promiseUtil.getValues(results); - return data; - }); - }, +/** + * Remove path on the device + * + * NOTE: mutates `options` + * + * @public + * + * @param {string} path - path to remove + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {string} command's output + */ +async function removePath(path, host, options = {}) { + const res = await runTMUtilUnixCommand.call(this, 'rm', `"${path}"`, host, options); + if (res.commandResult) { + throw new Error(`removePath: ${res.commandResult}`); + } +} - /** - * Transform name for valid string - * - * @param {String} partition - partition name - * @param {String} name - object name - * @param {String} subPath - sub path - * - * @returns {String} valid object path - */ - transformTMOSobjectName(partition, name, subPath) { - partition = partition || ''; - name = name || ''; - subPath = subPath || ''; +/** + * Request auth token + * + * NOTE: mutates `options` + * + * @private + * + * @param {string} host - host + * @param {string} username - device username + * @param {string} passphrase - device passphrase + * @param {Connection} [options] - function options + * + * @returns {{ token: string }} auth token + */ +async function requestAuthToken(host, username, passphrase, options) { + assert.string(username, 'username'); + assert.string(passphrase, 'passphrase'); + + const uri = '/mgmt/shared/authn/login'; + addJsonHeader(options); + options.method = 'POST'; + options.includeResponseObject = false; + options.noAuthHeader = true; + options.body = { + username, + password: passphrase, + loginProviderName: 'tmos' + }; - if (name) { - name = name.replace(/\//g, '~'); - } - if (partition) { - partition = `~${partition}`; - } else if (subPath) { - throw new Error('transformTMOSobjectName: When giving the subPath component include partition as well.'); - } - if (subPath && partition) { - subPath = `~${subPath}`; - } - if (name && partition) { - name = `~${name}`; - } - return `${partition}${subPath}${name}`; - }, + const data = await makeDeviceRequest(host, uri, options); + return { token: data.token.token }; +} - /** - * Check if path exists on the device - * - * @param {String} path - path - * @param {String} host - host - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * - * @returns {Promise} resolved when path exists on device - */ - pathExists(path, host, options) { - return this.runTMUtilUnixCommand('ls', `"${path}"`, host, options); - }, +/** + * Run Unix Command (ls/mv/rm) via REST API + * + * NOTE: mutates `options` + * + * @private + * + * @param {string} cmd - command to run (ls/mv/rm) + * @param {string} args - arguments to pass to command + * @param {string} host - host + * @param {object} [options] - function options, see 'makeDeviceRequest' + * + * @returns {string | string[]} command's output + */ +async function runTMUtilUnixCommand(cmd, args, host, options) { + const allowed = ['ls', 'rm', 'mv']; + assert.oneOf(cmd, allowed, 'cmd'); + + const uri = `/mgmt/tm/util/unix-${cmd}`; + addJsonHeader(options); + options.method = 'POST'; + options.includeResponseObject = false; + options.body = { + command: 'run', + utilCmdArgs: args + }; - /** - * Remove path on the device - * - * @param {String} path - path to remove - * @param {String} host - host - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * - * @returns {Promise} resolved when path removed - */ - removePath(path, host, options) { - return this.runTMUtilUnixCommand('rm', `"${path}"`, host, options); - }, + return makeDeviceRequest(host, uri, options); +} - /** - * Fetch device's info - * - * Fetches system info for arbitrary BIG-IP via REST API - * - * @param {String} host - host - * @param {Object} [options] - function options, see 'makeDeviceRequest' - * - * @returns {Promise} resolved with device's info - */ - getDeviceInfo(host, options) { - const uri = '/mgmt/shared/identified-devices/config/device-info'; - options = options || {}; - options.method = 'GET'; - options.includeResponseObject = false; - - return this.makeDeviceRequest(host, uri, options) - .then((res) => ({ - baseMac: res.baseMac, - build: res.build, - chassisSerialNumber: res.chassisSerialNumber, - halUuid: res.halUuid, - hostMac: res.hostMac, - hostname: res.hostname, - isClustered: res.isClustered, - isVirtual: res.isVirtual, - machineId: res.machineId, - managementAddress: res.managementAddress, - mcpDeviceName: res.mcpDeviceName, - physicalMemory: res.physicalMemory, - platform: res.platform, - product: res.product, - trustDomainGuid: res.trustDomainGuid, - version: res.version - })); - }, - - DeviceAsyncCLI +module.exports = { + clearHostDeviceInfo, + gatherHostDeviceInfo, + getHostDeviceInfo, + decryptAllSecrets, + downloadFileFromDevice, + encryptSecret, + executeShellCommandOnDevice, + getAuthToken, + getDeviceInfo, + getDeviceType, + getDeviceVersion, + isShellEnabled, + makeDeviceRequest, + pathExists, + removePath }; diff --git a/src/lib/utils/eventEmitter.js b/src/lib/utils/eventEmitter.js index 637a2e79..ca9da5e7 100644 --- a/src/lib/utils/eventEmitter.js +++ b/src/lib/utils/eventEmitter.js @@ -26,9 +26,9 @@ const EventEmitter2 = require('eventemitter2'); * * @returns {Error} error */ -function logSafeEmitException(event, error) { +function logSafeEmitException(event, error, isAsync) { if (this.logger) { - this.logger.exception(`${this.constructor.name}.safeEmit(Async), event "${event}", uncaught error`, error); + this.logger.exception(`${this.constructor.name}.safeEmit${isAsync ? 'Async' : ''}, event "${event}", uncaught error`, error); } return error; } @@ -37,34 +37,49 @@ function logSafeEmitException(event, error) { * Subclass of EventEmitter2 with safe 'emit' */ class SafeEventEmitter extends EventEmitter2 { + /** + * @inheritdoc + * + * NOTE: makes the function trully async. Original implementation is sync + */ + async emitAsync() { + return super.emitAsync.apply(this, arguments); + } + /** * Emit event * - * @returns {Boolean | Error} true if the event had listeners, false otherwise or Error if caught one + * @returns {boolean | Error} true if the event had listeners, false otherwise or Error if caught one */ safeEmit() { try { return this.emit.apply(this, arguments); } catch (emitErr) { - return logSafeEmitException.call(this, arguments[0], emitErr); + return logSafeEmitException.call(this, arguments[0], emitErr, false); } } /** * Emit async event * - * @async - * @returns {Promise | Error>} promise resolved with array of responses or + * @returns {Promise | Error>} promise resolved with array of responses or * Error if caught one (no rejection) */ - safeEmitAsync() { + async safeEmitAsync() { try { - return this.emitAsync.apply(this, arguments) - .catch((error) => logSafeEmitException.call(this, arguments[0], error)); + return await this.emitAsync.apply(this, arguments); } catch (emitErr) { - return Promise.resolve(logSafeEmitException.call(this, arguments[0], emitErr)); + return logSafeEmitException.call(this, arguments[0], emitErr, true); } } } module.exports = SafeEventEmitter; + +/** + * @typedef CallOnceOnly + * @type {function} + * @property {boolean} calledOnce - true when function was called already + * + * When .calledOnce returns true and callee trying to call a function then error will be thrown + */ diff --git a/src/lib/consumers/shared/httpUtil.js b/src/lib/utils/http.js similarity index 70% rename from src/lib/consumers/shared/httpUtil.js rename to src/lib/utils/http.js index 80b00aac..44bbcf79 100644 --- a/src/lib/consumers/shared/httpUtil.js +++ b/src/lib/utils/http.js @@ -16,8 +16,11 @@ 'use strict'; -const util = require('../../utils/misc'); -const requestsUtil = require('../../utils/requests'); +const http = require('http'); +const https = require('https'); + +const util = require('./misc'); +const requestsUtil = require('./requests'); /** * Process request headers @@ -113,7 +116,64 @@ function sendToConsumer(config) { }); } +/** + * Get custom options for HTTP transport from config + * + * @param {Object} config - config object containing opts + * + * @returns {Object} httpAgentOptions - Known/allowed keys only + */ +function getAgentOpts(config) { + const opts = config.httpAgentOpts || config.customOpts || []; + const allowedKeys = [ + 'keepAlive', + 'keepAliveMsecs', + 'maxSockets', + 'maxFreeSockets' + ]; + const ret = {}; + opts.filter((opt) => allowedKeys.indexOf(opt.name) !== -1) + .forEach((opt) => { + ret[opt.name] = opt.value; + }); + return ret; +} + +/** + * Get JSON string of opt keys + * + * @param {Object} opts - http agent options + * + * @returns {String} key + */ +function createHttpAgentOptsKey(opts) { + const keys = Object.keys(opts); + keys.sort(); + return JSON.stringify(keys.map((k) => [k, opts[k]])); +} + +/** + * Get HTTP/HTTPS agent with custom options + * + * @param {Object} config - Config object containing opts provide via declaration + * + * @returns {Object} agentConfig - contains key and agent instance + */ +function getAgent(config) { + const protocol = !config.connection ? 'http' : config.connection.protocol; + const agentOpts = getAgentOpts(config); + const agentConf = { + agentKey: createHttpAgentOptsKey(agentOpts), + agent: new (protocol === 'https' ? https.Agent : http.Agent)( + Object.assign({}, util.deepCopy(agentOpts)) + ) + }; + return agentConf; +} + module.exports = { sendToConsumer, - processHeaders + processHeaders, + getAgentOpts, + getAgent }; diff --git a/src/lib/utils/ihealth.js b/src/lib/utils/ihealth.js deleted file mode 100644 index ca6a38ee..00000000 --- a/src/lib/utils/ihealth.js +++ /dev/null @@ -1,855 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const fs = require('fs'); -const isEqual = require('lodash/isEqual'); -const path = require('path'); -const request = require('request'); - -const logger = require('../logger'); -const constants = require('../constants'); -const util = require('./misc'); -const deviceUtil = require('./device'); -const promiseUtil = require('./promise'); -const requestUtil = require('./requests'); - -/** @module ihealthUtil */ - -/** - * Initialize instance if not yet - * - * @returns {Promise} resolved once initialized - */ -function initializeIfNeeded() { - let p = Promise.resolve(); - if (!this._initialized) { - p = p.then(() => this.initialize()) - .then(() => { - this._initialized = true; - }); - } - return p.then(() => this); -} - -/** - * API to interact with a device - * - * @property {String} host - target host - * @property {Connection} connection - */ -class DeviceAPI { - /** - * Constructor - * - * @param {String} host - host - * @param {Object} [options] - options - * @param {Logger} [options.logger] - parent logger - * @param {Credentials} [options.credentials] - F5 Device credentials - * @param {Connection} [options.connection] - F5 Device connection settings - */ - constructor(host, options) { - options = options || {}; - this.host = host; - this.connection = options.connection || {}; - this.credentials = options.credentials || {}; - this.setLogger(options.logger || logger); - } - - /** - * Build Qkview command - * - * Note: on BIG-IP v11.6+ qkview utility uses /var/tmp as base dir for output. - * To avoid 'not enough space' error we have to build path using '../../ - * - * @public - * @param {String} qkviewFilePath - absolute path to Qkview file - * - * @returns {String} Qkview command - */ - buildQkviewCommand(qkviewFilePath) { - return `/usr/bin/qkview -C -f ../..${qkviewFilePath}`; - } - - /** - * Build Qkview file path - * - * @public - * @param {String} qkviewName - Qkview name - * - * @returns {String} Qkview file path - */ - buildQkviewPath(qkviewName) { - return path.join(constants.DEVICE_TMP_DIR, qkviewName); - } - - /** - * Create Qkview - * - * Note: should be run under a user with sufficient rights/access to avoid Qkview file corruption - * - * @public - * @param {String} qkviewName - Qkview name - * - * @returns {Promise} resolved with path to Qkview file - */ - createQkview(qkviewName) { - const qkviewPath = this.buildQkviewPath(qkviewName); - const dacli = new deviceUtil.DeviceAsyncCLI(this.host, this.getDACLIOptions()); - this.logger.debug(`Creating Qkview at ${qkviewPath}`); - return dacli.execute(this.buildQkviewCommand(qkviewPath)) - .then(() => qkviewPath); - } - - /** - * Create symbolic link for file - * - * @public - * @param {String} filePath - path to file - * @param {String} linkPath - path to link - * - * @returns {Promise} resolved once link created - */ - createSymLink(filePath, linkPath) { - this.logger.debug(`Creating symbolic link "${linkPath}" for "${filePath}"`); - return deviceUtil.executeShellCommandOnDevice(this.host, `ln -s \\"${filePath}\\" \\"${linkPath}\\"`, this.getDefaultRequestOptions()); - } - - /** - * Download file from device - * - * Workflow is following: - * - check if remote path exists - * - create symlink for file in directory designated for downloading files - * to avoid errors like 'not enough space' and etc. - * - check if symlink created - * - download file to local env - * - remove symlink despite on result - * - * @public - * @param {String} srcPath - path to file on device - * @param {String} dstPath - path to download file to - * - * @returns {Promise} resolved with path to downloaded file - */ - downloadFile(srcPath, dstPath) { - const remoteFileName = path.basename(srcPath); - let remoteDownloadPath; - let remoteDownloadURI; - let downloadError; - this.logger.debug(`Downloading file "${srcPath}" to "${dstPath}"`); - return this.pathExists(srcPath) - .then(() => this.getDownloadInfo()) - .then((downloadInfo) => { - remoteDownloadPath = path.join(downloadInfo.dir, remoteFileName); - remoteDownloadURI = downloadInfo.uri; - return this.createSymLink(srcPath, remoteDownloadPath) - .then(() => this.pathExists(remoteDownloadPath)) - .catch((err) => { - remoteDownloadPath = null; - return Promise.reject(err); - }); - }) - .then(() => deviceUtil.downloadFileFromDevice( - dstPath, - this.host, - `${remoteDownloadURI}${remoteFileName}`, - this.getDefaultRequestOptions() - )) - .catch((err) => { - downloadError = err; - }) - .then(() => { - if (!remoteDownloadPath) { - return Promise.resolve(); - } - return this.removeFile(remoteDownloadPath); - }) - .then(() => (downloadError ? Promise.reject(downloadError) : Promise.resolve(dstPath))); - } - - /** - * Request auth token - * - * @private - * @returns {Promise} resolved when auth data received - */ - getAuthToken() { - if (this.credentials.token) { - return Promise.resolve(); - } - // in case of optimization, replace with Object.assign - const options = util.deepCopy(this.connection); - return deviceUtil.getAuthToken(this.host, this.credentials.username, this.credentials.passphrase, options) - .then((token) => { - this.credentials.token = token.token; - }); - } - - /** - * Returns options for DACLI - * - * @private - * @returns {Object} options - */ - getDACLIOptions() { - return { - scriptName: `ts_ihealth_${util.generateUuid().slice(0, 5).replace(/-/g, '_')}`, - connection: util.deepCopy(this.connection), - credentials: util.deepCopy(this.credentials) - }; - } - - /** - * Returns default request' options - * - * @private - * @returns {Object} default request' options - */ - getDefaultRequestOptions() { - // remove parse-stringify in case of optimizations - const options = Object.assign({}, util.deepCopy(this.connection)); - options.credentials = { - token: this.credentials.token, - username: this.credentials.username - }; - return options; - } - - /** - * Fetch device's info - * - * @public - * @returns {Promise} resolved with device's info - */ - getDeviceInfo() { - return deviceUtil.getDeviceInfo(this.host, this.getDefaultRequestOptions()); - } - - /** - * Get download locations - * - * @public - * @returns {Promise} resolved with download info - */ - getDownloadInfo() { - return this.getDeviceInfo() - .then((deviceInfo) => { - const transferType = util.compareVersionStrings(deviceInfo.version, '<', '14.0') ? 'MADM' : 'BULK'; - const conf = constants.DEVICE_REST_API.TRANSFER_FILES[transferType]; - return { - dir: conf.DIR, - uri: conf.URI - }; - }); - } - - /** - * Calculate MD5 sum for file - * - * Note: creating MD5 sum using DACLI because it might take a while (depends on file size) and - * better to execute such processes in async way to avoid timeout errors - * - * @public - * @param {String} fileName - path to file to calculate MD5 for - * - * @returns {Promise} resolved with MD5Sum - */ - getMD5sum(fileName) { - const dacli = new deviceUtil.DeviceAsyncCLI(this.host, this.getDACLIOptions()); - const md5File = `${fileName}.md5sum`; - const md5Cmd = `md5sum "${fileName}" > "${md5File}"`; - let md5output; - this.logger.debug(`Calculating MD5 for "${fileName}" to "${md5File}"`); - return dacli.execute(md5Cmd) - .then(() => deviceUtil.executeShellCommandOnDevice(this.host, `cat \\"${md5File}\\"`, this.getDefaultRequestOptions())) - .then((output) => { - md5output = output; - return this.removeFile(md5File); - }) - .then(() => { - if (!md5output) { - return Promise.reject(new Error(`MD5 file "${md5File}" is empty!`)); - } - return Promise.resolve(md5output.split(' ', 1)[0].trim()); - }); - } - - /** - * Initialize - * - * @public - * @returns {Promise} resolved once initialized - */ - initialize() { - return this.getAuthToken() - .then(() => this); - } - - /** - * Check if file exists - * - * @public - * @param {String} fpath - path to to check - * - * @returns {Promise} resolved if file exists - */ - pathExists(fpath) { - this.logger.debug(`Checking "${fpath}" existence`); - return deviceUtil.pathExists(fpath, this.host, this.getDefaultRequestOptions()) - .then((files) => { - if (files.indexOf(fpath) === -1) { - return Promise.reject(new Error(`pathExists: ${fpath} doesn't exist`)); - } - return Promise.resolve(); - }); - } - - /** - * Remove file - * - * @public - * @param {String} fpath - path to file to remove - * - * @returns {Promise} resolved once file removed - */ - removeFile(fpath) { - this.logger.debug(`Removing "${fpath}"`); - return deviceUtil.removePath(fpath, this.host, this.getDefaultRequestOptions()) - .catch((removeErr) => this.logger.debugException(`Unable to remove "${fpath}"`, removeErr)); - } - - /** - * Set logger - * - * @public - * @param {Logger} parentLogger - parent logger - */ - setLogger(parentLogger) { - this.logger = parentLogger.getChild(this.constructor.name); - } -} - -/** - * API to interact with localhost device - */ -class LocalDeviceAPI extends DeviceAPI { - /** - * Constructor - * - * @param {Object} [options] - options - * @param {Logger} [options.logger] - parent logger - */ - constructor(options) { - super(constants.LOCAL_HOST, options); - } -} - -/** - * Qkview Manager - * - * Note: by default it tries to do all operations via REST API - * - * @property {Qkview} qkview - object with info about Qkview - */ -class QkviewManager { - /** - * Constructor - * - * @param {String} host - host, by default 'localhost' - * @param {Object} [options] - function options - * @param {Connection} [options.connection] - F5 Device connection settings - * @param {Credentials} [options.credentials] - F5 Device credentials - * @param {String} [options.downloadFolder = ''] - directory for download - * @param {Logger} [options.logger] - parent logger - */ - constructor(host, options) { - host = host || constants.LOCAL_HOST; - options = options || {}; - this.logger = (options.logger || logger).getChild(this.constructor.name); - this.localDevice = new LocalDeviceAPI({ logger: this.logger }); - this.remoteDevice = new DeviceAPI(host, { - connection: options.connection || {}, - credentials: options.credentials || {}, - logger: this.logger - }); - this.downloadFolder = options.downloadFolder || ''; - } - - /** - * Create Qkview via REST API (will try locally on fail) - * - * @public - * @returns {Promise} resolved with file name once Qkview file created - */ - createQkview() { - return this.remoteDevice.createQkview(this.generateQkviewName()); - } - - /** - * Download file from remote device - * - * @public - * @param {String} srcPath - path to file on remote device - * @param {String} dstPath - path to download file to - * - * @returns {Promise} resolved with path to downloaded file - */ - downloadFile(srcPath, dstPath) { - return initializeIfNeeded.call(this) - .then(() => this.remoteDevice.downloadFile(srcPath, dstPath)) - .then(() => promiseUtil.allSettled([ - this.remoteDevice.getMD5sum(srcPath), - this.localDevice.getMD5sum(dstPath) - ])) - .then((md5results) => { - md5results = promiseUtil.getValues(md5results); - if (!(md5results[0] && md5results[1] && md5results[0] === md5results[1])) { - return Promise.reject(new Error('MD5 sum for downloaded Qkview file !== MD5 sum for Qkview on remote host')); - } - return Promise.resolve(dstPath); - }); - } - - /** - * Generate Qkview file name - * - * @public - * @returns {String} Qkview file name - */ - generateQkviewName() { - const currentTime = (new Date()).getTime(); - const hrTime = process.hrtime(); - return `qkview_telemetry_${util.generateUuid().slice(0, 5).replace(/-/g, '_')}_${currentTime}_${hrTime[0]}${hrTime[1]}.tar.qkview`; - } - - /** - * Initialize - request auth tokens and etc. - * - * @public - * @returns {Promise} resolved once initialized - * @rejects {Error} when 'shouldDownload' is true and 'downloadFolder' not defined (or empty string) - */ - initialize() { - this.logger.debug('Initializing'); - return promiseUtil.allSettled([ - this.localDevice.initialize(), - this.remoteDevice.initialize() - ]) - .then((results) => { - // error will be thrown if rejection found - promiseUtil.getValues(results); - return promiseUtil.allSettled([ - this.localDevice.getDeviceInfo(), - this.remoteDevice.getDeviceInfo() - ]); - }) - .then((deviceInfoResults) => { - deviceInfoResults = promiseUtil.getValues(deviceInfoResults, true); - if (deviceInfoResults[0] && deviceInfoResults[1] - && isEqual(deviceInfoResults[0], deviceInfoResults[1])) { - this.logger.debug('Target host is localhost'); - // to speed-up process we can use API for local device instead - this.remoteDevice = this.localDevice; - } else if (!this.downloadFolder) { - throw new Error('Should specify directory for downloads'); - } - }) - .then(() => this); - } - - /** - * Start process of Qkview file creation and download it if needed - * - * @public - * @returns {Promise} resolved with path to Qkview file - */ - process() { - let qkviewPathOnDevice; - let localQkviewPath; - let opError; - - return initializeIfNeeded.call(this) - .then(() => this.createQkview()) - .then((qkviewPath) => { - if (this.localDevice === this.remoteDevice) { - localQkviewPath = qkviewPath; - return Promise.resolve(); - } - qkviewPathOnDevice = qkviewPath; - localQkviewPath = path.join(this.downloadFolder, path.basename(qkviewPathOnDevice)); - return this.downloadFile(qkviewPathOnDevice, localQkviewPath); - }) - .catch((err) => { - opError = err; - }) - .then(() => { - const promises = []; - if (qkviewPathOnDevice) { - promises.push(this.remoteDevice.removeFile(qkviewPathOnDevice)); - } - if (opError && localQkviewPath) { - promises.push(this.localDevice.removeFile(localQkviewPath)); - } - return promiseUtil.allSettled(promises); - }) - .then(() => (opError ? Promise.reject(opError) : localQkviewPath)); - } -} - -/** - * F5 iHealth API class - */ -class IHealthAPI { - /** - * Constructor - * - * @param {Credentials} credentials - F5 iHealth Service credentials - * @param {Object} [options = {}] - other options - * @param {Proxy} [options.proxy] - proxy settings for F5 iHealth Service connection - * @param {Logger} [options.logger] - logger instance - */ - constructor(credentials, options) { - if (!(credentials.username && credentials.passphrase)) { - throw new Error('Username and passphrase are required!'); - } - options = options || {}; - this.username = credentials.username; - this.passphrase = credentials.passphrase; - this.proxy = options.proxy || {}; - this.proxy.connection = this.proxy.connection || {}; - this.proxy.credentials = this.proxy.credentials || {}; - this.cookieJar = request.jar(); - this.logger = options.logger || logger.getChild('iHealthAPI'); - } - - /** - * Try authenticate to F5 iHealth Service with provided credentials - * - * @public - * @returns {Promise} resolved once got HTTP 200 OK - */ - authenticate() { - this.logger.debug('Authenticating to F5 iHealth Service'); - return Promise.resolve() - .then(() => { - const requestOptions = this.getDefaultRequestOptions(); - requestOptions.body = { - user_id: this.username, - user_secret: this.passphrase - }; - requestOptions.fullURI = constants.IHEALTH.SERVICE_API.LOGIN; - requestOptions.method = 'POST'; - requestOptions.headers['Content-type'] = 'application/json'; - requestOptions.expectedResponseCode = 200; - return this.sendRequest(requestOptions); - }); - } - - /** - * Fetch Qkview diagnostics data from F5 iHealth Service - * - * @public - * @property {String} qkviewURI - Qkview URI (returned after Qkview upload) - * - * @returns {Promise} resolved with Qkview diagnostics data - */ - fetchQkviewDiagnostics(qkviewURI) { - this.logger.debug(`Fetching Qkview diagnostics from "${qkviewURI}"`); - return Promise.resolve() - .then(() => { - const requestOptions = this.getDefaultRequestOptions(); - requestOptions.fullURI = `${qkviewURI}/diagnostics.json`; - requestOptions.method = 'GET'; - requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0+json'; - requestOptions.rawResponseBody = true; - return this.sendRequest(requestOptions); - }) - .then((res) => { - try { - res = JSON.parse(res); - } catch (err) { - return Promise.reject(new Error(`Invalid JSON response from F5 iHeath Service: ${res}`)); - } - if (util.isObjectEmpty(res.diagnostics)) { - return Promise.reject(new Error(`Missing 'diagnostics' in JSON response from F5 iHeath Service: ${JSON.stringify(res)}`)); - } - return res; - }); - } - - /** - * Default options for 'request' library - * - * @private - * @returns {Object} default 'request' options - */ - getDefaultRequestOptions() { - return { - headers: { 'User-Agent': constants.USER_AGENT }, - jar: this.cookieJar, - strictSSL: this.getStrictSSL(), // because we are connecting to F5 API - proxy: this.getProxy() - }; - } - - /** - * Build URL for proxy if configured - * - * @private - * @returns {String} proxy URL or empty string if not configured - */ - getProxy() { - let proxy; - if (this.proxy && this.proxy.connection.host) { - proxy = { - host: this.proxy.connection.host, - port: this.proxy.connection.port, - protocol: this.proxy.connection.protocol, - username: this.proxy.credentials.username, - passphrase: this.proxy.credentials.passphrase - }; - } - return proxy; - } - - /** - * Get value for strict SSL options - * - * @returns {Boolean} - */ - getStrictSSL() { - let strictSSL = true; // by default, because we are connecting to F5 API - if (this.proxy && this.proxy.connection.host) { - strictSSL = !this.proxy.connection.allowSelfSignedCert; - } - return strictSSL; - } - - /** - * Check if Qkview report is done - * - * @public - * @property {String} qkviewURI - Qkview URI (returned after Qkview upload) - * - * @returns {Promise} resolved with true when Qkview analyzing is done - */ - isQkviewReportReady(qkviewURI) { - this.logger.debug(`Checking Qkview diagnostics status at "${qkviewURI}"`); - return Promise.resolve() - .then(() => { - const requestOptions = this.getDefaultRequestOptions(); - requestOptions.fullURI = qkviewURI; - requestOptions.method = 'GET'; - requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0'; - requestOptions.continueOnErrorCode = true; - requestOptions.includeResponseObject = true; - return this.sendRequest(requestOptions); - }) - .then((res) => Promise.resolve(res[1].statusCode === 200)); - } - - /** - * Send request - * - * @private - * @param {Object} options - function options, see 'makeDeviceRequest' in 'utils/device.js' - * - * @returns {Promise} resolved with response - */ - sendRequest(options) { - return requestUtil.makeRequest(options); - } - - /** - * Upload Qkview file to F5 iHealth Service - * - * @public - * @param {String} qkviewFile - path to Qkview file - * - * @returns {Promise} resolved with URI of the Qkview uploaded to F5 iHealth service - */ - uploadQkview(qkviewFile) { - this.logger.debug(`Uploading Qkview "${qkviewFile}" to F5 iHealth Service`); - return Promise.resolve() - .then(() => { - const requestOptions = this.getDefaultRequestOptions(); - requestOptions.fullURI = constants.IHEALTH.SERVICE_API.UPLOAD; - requestOptions.method = 'POST'; - requestOptions.headers.Accept = 'application/vnd.f5.ihealth.api.v1.0+json'; - requestOptions.formData = { - qkview: fs.createReadStream(qkviewFile), - visible_in_gui: 'True' - }; - requestOptions.continueOnErrorCode = true; - requestOptions.includeResponseObject = true; - return this.sendRequest(requestOptions); - }) - .then((res) => { - let parsedBody; - let rejectReason; - const body = res[0]; - const respObj = res[1]; - - if (typeof body === 'object') { - parsedBody = body; - } else { - try { - parsedBody = JSON.parse(body); - } catch (parseErr) { - rejectReason = 'unable to parse response body'; - parsedBody = {}; - } - } - if (parsedBody.result === 'OK') { - if (parsedBody.location) { - this.logger.debug('Qkview uploaded to F5 iHealth service'); - return Promise.resolve(parsedBody.location); - } - rejectReason = 'unable to find "location" in response body'; - } - return Promise.reject(new Error(`Unable to upload Qkview to F5 iHealth server: ${rejectReason}: responseCode = ${respObj.statusCode} responseBody = ${JSON.stringify(body)}`)); - }); - } -} - -/** - * iHealth Manager to upload Qkview and poll diagnostics from the local device - * - * @class - * - * @property {IHealthAPI} api - instance of IHealthAPI - * @property {String} qkviewFile - path to Qkview file on the device - * @property {String} qkviewURI - Qkview' URI on F5 iHealth - */ -class IHealthManager { - /** - * Constructor - * - * @param {Credentials} credentials - F5 iHealth Service credentials - * @param {Object} options - function options - * @param {Logger} [options.logger] - parent logger - * @param {Proxy} [options.proxy] - proxy settings for F5 iHealth Service connection - * @param {String} [options.qkviewFile] - path to Qkview file on the device - * @param {String} [options.qkviewURI] - Qkview' URI on F5 iHealth - */ - constructor(credentials, options) { - options = options || {}; - this.api = new IHealthAPI(credentials, { - logger: (options.logger || logger).getChild('IHealthManager'), - proxy: options.proxy - }); - this.qkviewFile = options.qkviewFile; - this.qkviewURI = options.qkviewURI; - } - - /** - * Retrieve diagnostics data from F5 iHealth service - * - * @public - * @param {String} [qkviewURI] - Qkview' URI on F5 iHealth - * - * @return {Promise} resolved with diagnostics data (parsed JSON) - * @rejects {Error} when 'qkviewURI not set - */ - fetchQkviewDiagnostics(qkviewURI) { - qkviewURI = qkviewURI || this.qkviewURI; - if (!qkviewURI) { - return Promise.reject(new Error('Qkview URI not specified')); - } - return initializeIfNeeded.call(this) - .then(() => this.api.fetchQkviewDiagnostics(qkviewURI)); - } - - /** - * Check is F5 iHealth service done Qkview processing or not - * - * @public - * @param {String} [qkviewURI] - Qkview' URI on F5 iHealth - * - * @return {Promise} resolved with 'true' when Qkview processing done - * @rejects {Error} when 'qkviewURI not set - */ - isQkviewReportReady(qkviewURI) { - qkviewURI = qkviewURI || this.qkviewURI; - if (!qkviewURI) { - return Promise.reject(new Error('Qkview URI not specified')); - } - return initializeIfNeeded.call(this) - .then(() => this.api.isQkviewReportReady(qkviewURI)); - } - - /** - * Initialize - do auth and etc. - * - * @public - * @returns {Promise} resolved once preparation done - */ - initialize() { - return this.api.authenticate() - .then(() => this); - } - - /** - * Upload Qkview to F5 iHealth service - * - * @public - * @param {String} [qkviewFile] - path to Qkview file on the device - * - * @return {Promise} resolved with URI of the Qkview uploaded to F5 iHealth service - * @rejects {Error} when 'qkviewFile not set - */ - uploadQkview(qkviewFile) { - qkviewFile = qkviewFile || this.qkviewFile; - if (!qkviewFile) { - return Promise.reject(new Error('Path to Qkview file not specified')); - } - return initializeIfNeeded.call(this) - .then(() => this.api.uploadQkview(qkviewFile)) - .then((qkviewURI) => { - this.qkviewURI = qkviewURI; - return qkviewURI; - }); - } -} - -module.exports = { - DeviceAPI, - IHealthAPI, - IHealthManager, - LocalDeviceAPI, - QkviewManager -}; - -/** - * @typedef Connection - * @property {String} protocol - HTTP protocol - * @property {Integer} port - HTTP port - * @property {Boolean} allowSelfSignedCert - false - requires SSL certificates be valid, - * true - allows self-signed certs - */ -/** - * @typedef Credentials - * @property {String} username - username - * @property {String} passhprase - passphrase - * @property {String} [token] - F5 Device auth token for re-use - */ -/** - * @typedef Proxy - * @property {Credentials} credentials - credentials - * @property {Connection} connection - connection - */ diff --git a/src/lib/utils/misc.js b/src/lib/utils/misc.js index da17f88d..3e3de979 100644 --- a/src/lib/utils/misc.js +++ b/src/lib/utils/misc.js @@ -17,21 +17,20 @@ 'use strict'; const assignDefaults = require('lodash/defaultsDeep'); +const childProcess = require('child_process'); const cloneDeep = require('lodash/cloneDeep'); const clone = require('lodash/clone'); +const fs = require('fs'); const hasKey = require('lodash/has'); +const jsonDuplicateKeyHandle = require('json-duplicate-key-handle'); const mergeWith = require('lodash/mergeWith'); -const trim = require('lodash/trim'); -const objectGet = require('lodash/get'); -const childProcess = require('child_process'); -const fs = require('fs'); const net = require('net'); +const objectGet = require('lodash/get'); +const trim = require('lodash/trim'); +const nodeUtil = require('util'); +const { v4: uuidv4 } = require('uuid'); const v8 = require('v8'); -// deep require support is deprecated for versions 7+ (requires node8+) -const uuidv4 = require('uuid/v4'); -const jsonDuplicateKeyHandle = require('json-duplicate-key-handle'); - const constants = require('../constants'); /** @module utils/misc */ @@ -42,58 +41,6 @@ const constants = require('../constants'); const VERSION_COMPARATORS = ['==', '===', '<', '<=', '>', '>=', '!=', '!==']; -/** - * Convert async callback function to promise-based funcs - * - * Note: when error passed to callback then all other args will be attached - * to it and can be access via 'error.callbackArgs' property - * - * @sync - * @public - * - * @property {Object} module - origin module - * @property {String} funcName - function name - * - * @returns {Function} proxy function - */ -function proxyForNodeCallbackFuncs(module, funcName) { - return function () { - return new Promise((resolve, reject) => { - const args = Array.from(arguments); - args.push(function () { - const cbArgs = Array.from(arguments); - // error usually is first arg - if (cbArgs[0]) { - cbArgs[0].callbackArgs = cbArgs.slice(1); - reject(cbArgs[0]); - } else { - resolve(cbArgs.slice(1)); - } - }); - module[funcName].apply(module, args); - }); - }; -} - -/** - * Promisify FS module - * - * @sync - * @public - * @param {Object} fsModule - FS module - * - * @returns {Object} node FS module - */ -function promisifyNodeFsModule(fsModule) { - const newFsModule = Object.create(fsModule); - Object.keys(fsModule).forEach((key) => { - if (typeof fsModule[`${key}Sync`] !== 'undefined') { - newFsModule[key] = proxyForNodeCallbackFuncs(fsModule, key); - } - }); - return newFsModule; -} - /** * 'traverseJSON' block - START */ @@ -557,6 +504,7 @@ class Chunks { throw new Error(`'maxChunkSize' should be > 0, got '${options.maxChunkSize}' (${typeof options.maxChunkSize})`); } + /** define static read-only props that should not be overriden */ Object.defineProperties(this, { currentChunkSize: { get() { return this._current ? this._current.size : 0; } @@ -644,8 +592,6 @@ module.exports = { Chunks, createJSONObjectSecretsMaskFunc, createJSONStringSecretsMaskFunc, - proxyForNodeCallbackFuncs, - promisifyNodeFsModule, traverseJSON, /** @@ -935,7 +881,8 @@ module.exports = { return Promise.all([connectPromise, timeoutPromise]) .then(() => true) .catch((e) => { - throw new Error(`networkCheck: ${e}`); + e.message = `networkCheck: ${e.message || e}`; + throw e; }); }, @@ -1091,13 +1038,18 @@ module.exports = { * * @param {function} cb - callback to register * - * @returns {void} once registered + * @returns {function} callback to call to deregister */ onApplicationExit(cb) { - process.on('SIGINT', cb); - process.on('SIGTERM', cb); - process.on('SIGHUP', cb); - process.on('exit', cb); + const events = [ + 'SIGINT', + 'SIGTERM', + 'SIGHUP', + 'exit' + ]; + events.forEach((evt) => process.on(evt, cb)); + + return () => events.forEach((evt) => process.removeListener(evt, cb)); }, /** @@ -1108,7 +1060,7 @@ module.exports = { childProcess: (function promisifyNodeChildProcessModule(cpModule) { const newCpModule = Object.create(cpModule); ['exec', 'execFile'].forEach((key) => { - newCpModule[key] = proxyForNodeCallbackFuncs(cpModule, key); + newCpModule[key] = nodeUtil.promisify(cpModule[key]); }); return newCpModule; }(childProcess)), @@ -1118,7 +1070,15 @@ module.exports = { * * @see fs */ - fs: promisifyNodeFsModule(fs) + fs: (function promisifyNodeFsModule(fsModule) { + const newFsModule = Object.create(fsModule); + Object.keys(fsModule).forEach((key) => { + if (typeof fsModule[`${key}Sync`] !== 'undefined') { + newFsModule[key] = nodeUtil.promisify(fsModule[key]); + } + }); + return newFsModule; + }(fs)) }; /** diff --git a/src/lib/utils/moduleLoader.js b/src/lib/utils/moduleLoader.js index b77c362d..54bf3fe1 100644 --- a/src/lib/utils/moduleLoader.js +++ b/src/lib/utils/moduleLoader.js @@ -16,7 +16,7 @@ 'use strict'; -const logger = require('../logger'); +const logger = require('../logger').getChild('ModuleLoader'); /** * ModuleLoader class - reusable functions for loading/unloading Node packages diff --git a/src/lib/utils/promise.js b/src/lib/utils/promise.js index baaa9b3c..9f584a25 100644 --- a/src/lib/utils/promise.js +++ b/src/lib/utils/promise.js @@ -242,6 +242,23 @@ const promiseUtils = module.exports = { return false; }; return promise; + }, + + /** @returns {{ promise: Promise, reject: function, resolve: function}} object with Promise and resolvers */ + withResolvers() { + let resolve; + let reject; + + const ret = { + promise: new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }) + }; + ret.resolve = resolve; + ret.reject = reject; + + return ret; } }; diff --git a/src/lib/utils/requests.js b/src/lib/utils/requests.js index eead27fd..867f8a0f 100644 --- a/src/lib/utils/requests.js +++ b/src/lib/utils/requests.js @@ -17,12 +17,14 @@ 'use strict'; const request = require('request'); + const constants = require('../constants'); +const logger = require('../logger').getChild('requests'); const util = require('./misc'); /** @module requestsUtil */ -/* Helper functions for making requests - */ + +const LOG_LEVEL = 'verbose'; // cleanup options. Update tests (test/unit/utils/requestsTests.js) when adding new value const MAKE_REQUEST_OPTS_TO_REMOVE = [ @@ -183,10 +185,34 @@ const makeRequest = function () { }); return new Promise((resolve, reject) => { + let reqID; + let ts; + if (logger.isLevelAllowed(LOG_LEVEL)) { + reqID = util.generateUuid().slice(0, 5); + ts = Date.now(); + // log now to show what was passed to the lib + logger[LOG_LEVEL]({ + reqID, + options: Object.assign({}, options, { agent: typeof options.agent !== 'undefined' }), + timestamp: ts + }); + } + // using request.get, request.post, etc. - useful during unit test mocking request[options.method.toLowerCase()](options, (err, res, body) => { + if (logger.isLevelAllowed(LOG_LEVEL)) { + logger[LOG_LEVEL]({ + body, + duration: Date.now() - ts, + error: `${err}`, + reqID, + statusCode: res ? res.statusCode : null + }); + } + if (err) { - reject(new Error(`HTTP error: ${err}`)); + err.message = `HTTP Error: ${err.message || err}`; + reject(err); } else { if (!rawResponseBody) { try { diff --git a/src/lib/utils/service.js b/src/lib/utils/service.js index ac2c2a02..f569a199 100644 --- a/src/lib/utils/service.js +++ b/src/lib/utils/service.js @@ -44,6 +44,9 @@ const SafeEventEmitter = require('./eventEmitter'); * retarting ------| * +--> stopping * stopping ----------> stopped + * +--> restarting + * destroyed ------| + * +--> starting * * `stopped` allowed to `start` or `restart` * `running` allowed to `stop` or `restart` @@ -61,6 +64,14 @@ const serviceFsm = new machina.BehavioralFsm({ _onEnter(service) { service.logger.debug('destroyed.'); service.ee.emitAsync('destroyed'); + }, + restart(service, options) { + this.deferUntilTransition(service); + this.transition(service, 'stopped', options); + }, + start(service) { + this.deferUntilTransition(service); + this.transition(service, 'stopped'); } }, restarting: { @@ -78,25 +89,38 @@ const serviceFsm = new machina.BehavioralFsm({ Promise.resolve() .then(() => { - if (!service._stopRequested) { + if (!(service.__stopRequested || service.__firstStart)) { return Promise.resolve() .then(() => { + // call ._onStop in any case to allow instance cleanup prev state service.logger.debug(`stopping... ${attemptsStr}`); - return service._onStop(true); + return service._onStop({ destroy: false, restart: true }); }) + // catch and log error without crashing to let service start + // and do no stuck in restart loop .catch((error) => service.logger.debugException(`caught error on attempt to stop ${attemptsStr}:`, error)); } return null; }) .then(() => new Promise((resolve) => { - if (!service._stopRequested) { + if (!service.__stopRequested) { service.logger.debug(`starting... ${attemptsStr}`); - service._fatalErrorHandler = this._createFatalErroFn((fatalError) => { + service.__fatalErrorHandler = this._createFatalErroFn((fatalError) => { resolve(fatalError); resolve = null; }); + + const coldStart = service.__firstStart; + service.__firstStart = false; + Promise.resolve() - .then(() => service._onStart(service._fatalErrorHandler.onError)) + .then(() => service._onStart( + service.__fatalErrorHandler.onError, + { + coldStart, + restart: !coldStart + } + )) .then( () => resolve && resolve(), (err) => resolve && resolve(err) @@ -107,20 +131,21 @@ const serviceFsm = new machina.BehavioralFsm({ })) .catch((error) => error) .then((error) => { - if (service._fatalErrorHandler && !error) { - error = service._fatalErrorHandler.error(); + if (service.__fatalErrorHandler && !error) { + error = service.__fatalErrorHandler.error(); } - if (!error && service._stopRequested) { + if (!error && service.__stopRequested) { error = new Error('Service stop requested!'); } if (error) { - service._fatalErrorHandler.cancel(); + service.__fatalErrorHandler.cancel(); service.logger.debugException(`failed to start due error ${attemptsStr}:`, error); } - if (service._stopRequested || (error && currentAttempt >= attempts)) { + if (service.__stopRequested || (error && currentAttempt >= attempts)) { error.$restartFailed = true; - this.transition(service, 'stopping', error); + error.$restartFailed = true; // pass-through to `.stop()` + this.transition(service, 'stopping', error, !!service.__destroyRequested); } else if (error) { setTimeout(inner, delay || 0); } else { @@ -135,16 +160,19 @@ const serviceFsm = new machina.BehavioralFsm({ setImmediate(inner); }, _onExit(service) { - service._stopRequested = undefined; + service.__destroyRequested = undefined; + service.__stopRequested = undefined; }, destroy(service) { service.logger.debug(`termination requested [state=${this.getState(service)}]`); + service.__destroyRequested = true; + this.deferUntilTransition(service); this.handle(service, 'stop'); }, stop(service) { service.logger.debug(`stop requested [state=${this.getState(service)}]`); - service._stopRequested = true; + service.__stopRequested = true; } }, running: { @@ -152,12 +180,12 @@ const serviceFsm = new machina.BehavioralFsm({ service.ee.emitAsync('running'); }, _onExit(service) { - service._fatalErrorHandler.cancel(); + service.__fatalErrorHandler.cancel(); }, destroy(service) { service.logger.debug(`termination requested [state=${this.getState(service)}]`); this.deferUntilTransition(service); - this.handle(service, 'stop'); + this.handle(service, 'stop', null, true); }, fatalError(service, error) { if (service.restartsEnabled) { @@ -176,22 +204,29 @@ const serviceFsm = new machina.BehavioralFsm({ } this.transition(service, 'restarting', options); }, - stop(service, error) { + stop(service, error, destroy) { service.logger.debug(`stop requested [state=${this.getState(service)}]`); - this.transition(service, 'stopping', error); + this.transition(service, 'stopping', error, destroy); } }, starting: { _onEnter(service) { // unique ID to prevent situations when the service entered `starting` // state while the prev `starting` promise not resolved yet (e.g. fatalError occured) - const startID = service._startTimestamp = hrtimestamp(); + const startID = service.__startTimestamp = hrtimestamp(); Promise.resolve() .then(() => { - service._fatalErrorHandler = this._createFatalErroFn( + service.__fatalErrorHandler = this._createFatalErroFn( (fatalError) => this.handle(service, 'fatalError', fatalError) ); - return service._onStart(service._fatalErrorHandler.onError); + + const coldStart = service.__firstStart; + service.__firstStart = false; + + return service._onStart( + service.__fatalErrorHandler.onError, + { coldStart, restart: false } + ); }) .then( // call handler here to avoid unnecessary transition @@ -202,12 +237,12 @@ const serviceFsm = new machina.BehavioralFsm({ ); }, _onExit(service) { - service._startTimestamp = undefined; + service.__startTimestamp = undefined; }, destroy(service) { service.logger.debug(`termination requested [state=${this.getState(service)}]`); this.deferUntilTransition(service); - this.handle(service, 'stop'); + this.handle(service, 'stop', true); }, fatalError(service, error) { // at that time the Promise in _onEnter still might be not resolved/rejected yet @@ -216,36 +251,41 @@ const serviceFsm = new machina.BehavioralFsm({ this.handle(service, 'startingFailed', error); }, startingDone(service, startID) { - if (startID === service._startTimestamp) { + if (startID === service.__startTimestamp) { this.transition(service, 'running'); } else { service.logger.debug('ignoring successfull start that happened out of order'); } }, startingFailed(service, error, startID) { - if (!startID || startID === service._startTimestamp) { - service._fatalErrorHandler.cancel(); + if (!startID || startID === service.__startTimestamp) { + service.__fatalErrorHandler.cancel(); service.logger.debug(`failed to start due error [state=${this.getState(service)}]`); this.transition(service, 'stopping', error); } else { service.logger.debugException('ignoring failed start that happened out of order due error:', error); } }, - stop(service) { - service._fatalErrorHandler.cancel(); + stop(service, destroy) { + service.__fatalErrorHandler.cancel(); service.logger.debug(`stop requested [state=${this.getState(service)}]`); - this.transition(service, 'stopping'); + this.transition(service, 'stopping', null, destroy); } }, stopped: { _onEnter(service, error) { - if (this.getPriorState(service) !== 'stopped') { - if (error) { - service.logger.debugException(`stopped due error [state=${this.getState(service)}]:`, error); - service.ee.emitAsync('failed', error); - } else { - service.ee.emitAsync('stopped'); - } + if (this.needsInitialization(service)) { + // initial state + service.__destroyRequested = undefined; + service.__fatalErrorHandler = undefined; + service.__firstStart = true; + service.__startTimestamp = undefined; + service.__stopRequested = undefined; + } else if (error) { + service.logger.debugException(`stopped due error [state=${this.getState(service)}]:`, error); + service.ee.emitAsync('failed', error); + } else { + service.ee.emitAsync('stopped'); } }, destroy(service) { @@ -262,13 +302,11 @@ const serviceFsm = new machina.BehavioralFsm({ } }, stopping: { - _onEnter(service, error) { + _onEnter(service, error, destroy) { Promise.resolve() - .then(() => service._onStop(false)) - .then( - () => this.transition(service, 'stopped', error), - (stopError) => this.transition(service, 'stopped', stopError) - ); + .then(() => service._onStop({ destroy: !!destroy, restart: false })) + .then(() => error, (stopError) => stopError) + .then((reason) => this.transition(service, 'stopped', reason)); }, destroy(service) { service.logger.debug(`termination requested [state=${this.getState(service)}]`); @@ -352,6 +390,7 @@ const serviceFsm = new machina.BehavioralFsm({ * @returns {Promise} resolved with `true` if action succeed or rejected with error */ _promisifyActionHandle(service, action, successEvents, failureEvents) { + // promise body executes in sync way, according to standard return new Promise((resolve, reject) => { successEvents = Array.isArray(successEvents) ? successEvents : [successEvents]; failureEvents = Array.isArray(failureEvents) ? failureEvents : [failureEvents]; @@ -423,6 +462,16 @@ const serviceFsm = new machina.BehavioralFsm({ return this.getState(service) === 'destroyed'; }, + /** + * @param {Service} service + * + * @returns {boolean} true if service need initialization + */ + needsInitialization(service) { + const prevState = this.getPriorState(service); + return prevState === 'destroyed' || prevState === 'stopped'; + }, + /** * @param {Service} service * @@ -459,6 +508,7 @@ const serviceFsm = new machina.BehavioralFsm({ * @returns {Promise} resolve with `true` if service restarted or `false` if restart not allowed */ restart(service, options) { + // the whole func is sync, even `_promisifyActionHandle`. So, no concurrent `restarts` allowed options = options || {}; const retryOpts = { attempts: (Number.isSafeInteger(options.attempts) && Math.abs(options.attempts)) || 1, @@ -511,23 +561,22 @@ serviceFsm.on('transition', (data) => data.client.ee.emitAsync('transition', dat */ class Service { /** - * @param {logger.Logger} [logger] - logger instance + * @param {logger.Logger} [logger] - parent logger instance */ - constructor(_logger) { - // create read-only properties + constructor(parentLogger) { + /** define static read-only props that should not be overriden */ Object.defineProperties(this, { ee: { value: new SafeEventEmitter() + }, + logger: { + value: (parentLogger || logger).getChild(this.constructor.name) } }); - this.logger = _logger || logger; - this.restartsEnabled = true; + this.ee.logger = this.logger.getChild('ee'); this.ee.on('transition', (data) => this.logger.debug(`transition from "${data.fromState}" to "${data.toState}" (action=${data.action})`)); - - this._fatalErrorHandler = undefined; - this._startTimestamp = undefined; - this._stopRequested = undefined; + this.restartsEnabled = true; } /** @returns {Promise} resolved with true when service destroyed or if it was destroyed already */ @@ -586,6 +635,10 @@ class Service { * Configure and start the service (should be overriden by child class) * * @param {function} onFatalError - function to call on fatal error to restart the service + * @param {object} info - additional info + * @param {boolean} info.coldStart - true if the service was started first time after creating or once destroyed + * @param {boolean} info.restart - true if the service was started due calling `.restart()`. + * NOTE: set to `false` on cold start. */ _onStart() { this.logger.debug('running...'); @@ -594,7 +647,9 @@ class Service { /** * Stop the service (should be overriden by child class) * - * @param {boolean} [restart] - true if service going to be restarted + * @param {object} info - additional info + * @param {boolean} info.destroy - true if the service was stopped due calling `.destroy()`. + * @param {boolean} info.restart - true if the service was started due calling `.restart()`. */ _onStop() { this.logger.debug('stopping...'); diff --git a/src/lib/utils/structures/circularArray.js b/src/lib/utils/structures/circularArray.js index 1f6729c1..63431cdf 100644 --- a/src/lib/utils/structures/circularArray.js +++ b/src/lib/utils/structures/circularArray.js @@ -130,7 +130,6 @@ class CircularArray { } if (!(this._storage && this._storage.length === prealloc)) { - // TODO: provide option for TypedArray this._storage = new Array(prealloc); } if (this._holeSet) { diff --git a/src/lib/utils/systemStats.js b/src/lib/utils/systemStats.js deleted file mode 100644 index b5a25620..00000000 --- a/src/lib/utils/systemStats.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const mustache = require('mustache'); - -const constants = require('../constants'); -const util = require('./misc'); - -/** @module systemStatsUtil */ - -/** - * Comparison functions - * TODO: these are mostly BIG-IP specific, we might want to move these out when we support other platforms - */ -const CONDITIONAL_FUNCS = { - /** - * Compare device versions - * - * @param {Object} contextData - context data - * @param {Object} contextData.deviceVersion - device's version to compare - * @param {String} versionToCompare - version to compare against - * - * @returns {boolean} true when device's version is greater or equal - */ - deviceVersionGreaterOrEqual(contextData, versionToCompare) { - const deviceVersion = contextData.deviceVersion; - if (deviceVersion === undefined) { - throw new Error('deviceVersionGreaterOrEqual: context has no property \'deviceVersion\''); - } - return util.compareVersionStrings(deviceVersion, '>=', versionToCompare); - }, - - /** - * Compare provisioned modules - * - * @param {Object} contextData - context data - * @param {Object} contextData.provisioning - provision state of modules to compare - * @param {String} moduleToCompare - module to compare against - * - * @returns {boolean} true when device's module is provisioned - */ - isModuleProvisioned(contextData, moduleToCompare) { - const provisioning = contextData.provisioning; - if (provisioning === undefined) { - throw new Error('isModuleProvisioned: context has no property \'provisioning\''); - } - return ((provisioning[moduleToCompare] || {}).level || 'none') !== 'none'; - }, - - /** - * Compares Bash state between device and expected Bash state - * - * @param {Object} contextData - context data - * @param {Boolean} contextData.bashDisabled - whether or not Bash has been disabled - * @param {Boolean} stateToCompare - state of Bash to check against - * - * @returns {boolean} true when device's bash state is the same as the desired state - */ - isBashDisabled(contextData, stateToCompare) { - const bashDisabled = contextData.bashDisabled; - if (bashDisabled === undefined) { - throw new Error('isBashDisabled: context has no property \'bashDisabled\''); - } - return bashDisabled === stateToCompare; - } -}; - -/** - * Evaluate conditional block - * - * @param {Object} contextData - contextData object - * @param {Object} conditionalBlock - block to evaluate, where object's key - conditional operator - * object's value - params for that operator - * - * @returns {boolean} conditional result - */ -function resolveConditional(contextData, conditionalBlock) { - return Object.keys(conditionalBlock).every((key) => { - const func = CONDITIONAL_FUNCS[key]; - if (func === undefined) { - throw new Error(`Unknown property '${key}' in conditional block`); - } - return func(contextData, conditionalBlock[key]); - }); -} - -/** - * Render property using mustache template system. - * - * Note: mutates 'property' - * - * @param {Object} contextData - context object - * @param {Object} property - property object - * - * @returns {void} when finished - */ -function renderTemplate(contextData, property) { - util.traverseJSON(property, (parent, key) => { - const val = parent[key]; - if (typeof val === 'string') { - const startIdx = val.indexOf('{{'); - if (startIdx !== -1 && val.indexOf('}}', startIdx) > startIdx) { - parent[key] = mustache.render(val, contextData); - } - } - }); -} - -/** - * Property pre-processing to resolve conditionals - * - * Note: mutates 'property' - * - * @param {Object} contextData - context object - * @param {Object} property - property object - * - * @returns {void} when finished - */ -function preprocessProperty(contextData, property) { - // put 'property' inside of object to be able to - // process 'if' on the top level - util.traverseJSON({ property }, (parent, key) => { - const val = parent[key]; - // run while 'if' block exist on current level - while (typeof val === 'object' - && !Array.isArray(val) - && val !== null - && typeof val.if !== 'undefined' - ) { - const block = resolveConditional(contextData, val.if) ? val.then : val.else; - // delete blocks at first to avoid collisions with nested blocks - delete val.if; - delete val.then; - delete val.else; - - if (typeof block === 'object' && !Array.isArray(block)) { - Object.assign(val, block); - } - } - }); -} - -module.exports = { - /** - * Render property based on template and conditionals - * - * Note: mutates 'property' - * - * @param {Object} contextData - contextData object - * @param {Object} property - property object - * - * @returns {Object} rendered property - */ - renderProperty(contextData, property) { - renderTemplate(contextData, property); - preprocessProperty(contextData, property); - return property; - }, - - /** - * Split key - * - * @param {String} key - key to split - * - * @returns {Object} Return data formatted like { rootKey: 'key, childKey: 'key' } - */ - splitKey(key) { - const idx = key.indexOf(constants.STATS_KEY_SEP); - const ret = { rootKey: key.slice(0, idx === -1 ? key.length : idx) }; - if (idx !== -1) { - ret.childKey = key.slice(idx + constants.STATS_KEY_SEP.length, key.length); - } - return ret; - } -}; diff --git a/src/lib/utils/taskQueue.js b/src/lib/utils/taskQueue.js new file mode 100644 index 00000000..01666f6b --- /dev/null +++ b/src/lib/utils/taskQueue.js @@ -0,0 +1,354 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-unused-expressions */ + +const Queue = require('heap'); + +const assert = require('./assert'); +const defaultLogger = require('../logger').getChild('TaskQueue'); +const generateUuid = require('./misc').generateUuid; + +/** + * @module utils/taskQueue + * + * @typedef {import('../logger').Logger} Logger + */ + +const NO_TASK = Symbol('NO_TASK'); + +/** @returns {string} unique ID */ +function uniqueID() { + return generateUuid().slice(0, 5); +} + +/** + * @param {[sec: number, nanosec: number]} ts - high-resolution timestamp + * + * @returns {number} high-resolution time to milliseconds + */ +function hrToMs(ts) { + // seconds -> nanosec -> milisec + return ((ts[0] * 1e9 + ts[1]) / 1e6); +} + +/** + * Calculate delta for high-resolution timestamps (result = end - start) + * + * @param {[number, number]} start - high-resolution timestamp + * + * @returns {number} delta in miliseconds + */ +function hrDeltaMs(start) { + return (hrToMs(process.hrtime()) - hrToMs(start)).toFixed(5); +} + +/** + * @this WorkerCtx + * + * @param {Task} task + * + * @returns {{called: boolean, fn: function, startTime: [number, number]}} wrapper object + */ +function nextTaskWrapper(task) { + const tid = task.id; + const ctx = { + called: false, + fn: () => { + if (ctx.called) { + this.logger.warning(`"done" callback was called multiple times for task "${tid}" (${hrDeltaMs(ctx.startTime)} ms. elapsed.)`); + } else { + ctx.called = true; + this.logger.verbose(`Task "${tid}" finished in ${hrDeltaMs(ctx.startTime)} ms.`); + this.doTask(); + } + }, + startTime: process.hrtime() + }; + return ctx; +} + +/** + * @this WorkerCtx + * + * @returns {void} once next task executed or scheduled + */ +async function doTask() { + let task = NO_TASK; + if (this.stopPromise === null) { + try { + task = this.getNextTask(); + } catch (err) { + this.logger.exception('Unable to fetch next task:', err); + } + } + + if (task === NO_TASK) { + this.idle = true; + this.stopPromise && this.stopPromise.resolve(); + return; + } + + this.logger.verbose(task); + const wrapper = nextTaskWrapper.call(this, task); + + try { + await this.fn(task.task, wrapper.fn, { taskID: task.id, workerID: this.worker.id }); + } catch (err) { + this.logger.exception( + `Task "${task.id}" failed with uncaught error after ${hrDeltaMs(wrapper.startTime)} ms.:`, + err + ); + !wrapper.called && wrapper.fn(); + } +} + +/** + * Task Queue Worker Private Class + * + * @class + * @private + * + * @property {function} do - starts activity + * @property {string} id - instance ID + * @property {function(): boolean} isBusy - returns `true` when the worker is busy + * @property {function(): Promise} stop - stops worker + */ +class Worker { + /** + * Constructor + * + * @param {string} id - instance ID + * @param {TaskCallback} fn - function to execute a task on + * @param {function(): Task} getNextTask - returns next task + * @param {Logger} logger - parent logger + */ + constructor(id, fn, getNextTask, logger) { + assert.string(id, 'id'); + assert.function(fn, 'fn'); + assert.function(getNextTask, 'getNextTask'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + + const ctx = { + fn, + getNextTask, + idle: true, + logger: logger.getChild(id), + stopPromise: null, + worker: this + }; + ctx.doTask = setImmediate.bind(null, doTask.bind(ctx)); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + do: { + value: () => { + if (!this.isBusy()) { + ctx.idle = false; + ctx.doTask(); + } + } + }, + id: { + value: id + }, + isBusy: { + value: () => !ctx.idle + }, + stop: { + value: async () => { + if (!this.isBusy()) { + ctx.logger.verbose('Idle, stopped'); + return; + } + + ctx.logger.verbose('Stopping'); + + if (ctx.stopPromise === null) { + let resolve; + ctx.stopPromise = (new Promise((done) => { + resolve = done; + })) + .then(() => { + ctx.stopPromise = null; + ctx.logger.verbose('Stopped'); + }); + + ctx.stopPromise.resolve = resolve; + } + + await ctx.stopPromise; + } + } + }); + } +} + +/** + * Task Queue Class + * + * @class + * @public + * + * @property {function(): void} clear - clears pending tasks + * @property {number} concurrency - number of workers + * @property {function(): boolean} isIdle - returns `true` if all workers are idle + * @property {number} maxSize - max number of pending tasks in the queue + * @property {string} name - Task Queue name + * @property {TaskCallback: boolean} push - returns `true` if task was successfuly added to the queue. + * @property {function(): void} resume - resume all activities + * @property {function(): void} stop - stops all workers + */ +class TaskQueue { + /** + * Constructor + * + * @param {TaskCallback} fn - target function + * @param {object} [options] - options + * @param {number} [options.concurrency = 1] - number of workers + * @param {Logger} [option.logger] - parent logger + * @param {string} [options.name] - name + * @param {boolean} [options.usePriority = false] - if true, the queue will use task.priority when processing tasks + */ + constructor(fn, { + concurrency = 1, + logger = defaultLogger, + maxSize = Number.MAX_SAFE_INTEGER, + name = `TaskQueue_${uniqueID()}`, + usePriority = false + } = {}) { + assert.function(fn, 'fn'); + assert.safeNumberGrEq(concurrency, 1, 'concurrency'); + assert.instanceOf(logger, defaultLogger.constructor, 'logger'); + assert.safeNumberBetweenInclusive(maxSize, 1, Number.MAX_SAFE_INTEGER, 'maxSize'); + assert.string(name, 'name'); + assert.boolean(usePriority, 'usePriority'); + + const queue = usePriority + ? new Queue((a, b) => a.task.priority - b.task.priority) + : new Queue(); + const getNextTask = () => (queue.size() ? queue.pop() : NO_TASK); + const workers = []; + + let siID = null; + logger = logger.getChild(name); + + /** define static read-only props that should not be overriden */ + Object.defineProperties(this, { + clear: { + value: () => { + queue.clear(); + } + }, + concurrency: { + value: concurrency + }, + isIdle: { + value: () => workers.every((w) => !w.isBusy()) + }, + maxSize: { + value: maxSize + }, + name: { + value: name + }, + push: { + value: (task) => { + if (queue.size() >= this.maxSize) { + return false; + } + + queue.push({ task, id: uniqueID() }); + this.resume(); + return true; + } + }, + resume: { + value: () => { + if (siID === null && this.size() > 0) { + siID = setImmediate(() => { + siID = null; + + const wLen = workers.length; + const qLen = queue.size(); + + for (let workerIdx = 0, jobIdx = 0; workerIdx < wLen && jobIdx < qLen; workerIdx += 1) { + if (!workers[workerIdx].isBusy()) { + workers[workerIdx].do(); + jobIdx += 1; + } + } + }); + } + } + }, + size: { + value: () => queue.size() + }, + stop: { + value: async () => { + if (siID !== null) { + clearImmediate(siID); + siID = null; + } + return Promise.all(workers.map((w) => w.stop())); + } + } + }); + + for (let i = 0; i < this.concurrency; i += 1) { + const id = `worker_${i + 1}`; + workers.push(new Worker( + id, fn, getNextTask, logger + )); + } + logger.verbose(`${workers.length} workers created`); + } +} + +module.exports = TaskQueue; + +/** + * @typedef {object} TaskInfo + * @property {string} taskID - task ID + * @property {string} workerID - worker ID + */ +/** + * @typedef {object} Task + * @property {any} tasks - task's data + * @property {string} id unique ID + */ +/** + * Signature for a task that is processed by the TaskQueue + * NOTE: callback should be called only once + * + * @callback TaskCallback + * @param {any} task - task to execute + * @param {function} done - callback after task execution regardless of success/failure + * @param {TaskInfo} info - additional task info available once task is bound/added to queue + */ +/** + * @typedef {object} WorkerCtx + * @property {function} fn - function to call to execute a task + * @property {function(): TaskInfo} getNextTask - returns next task + * @property {boolean} idle - worker's idle status + * @property {Logger} logger - logger + * @property {null | Promise} stopPromise - created once worker.stop() called + * @property {function} stopPromise.resolve - function to call to let know the worker stopped + * @property {Worker} worker - worker instance + */ diff --git a/src/lib/utils/timers.js b/src/lib/utils/timers.js index 737ce862..bff89286 100644 --- a/src/lib/utils/timers.js +++ b/src/lib/utils/timers.js @@ -18,6 +18,8 @@ /** @module timers */ +const NotImplementedError = require('../errors').NotImplementedError; + /** * Interface for classes that represents a recurring timer * @@ -28,7 +30,7 @@ class TimerInterface { * @returns {boolean} true if instance is active */ isActive() { - throw new Error('Not implemented'); + throw new NotImplementedError(); } /** @@ -37,7 +39,7 @@ class TimerInterface { * @returns {Promise} resolved once scheduled */ start() { - throw new Error('Not implemented'); + throw new NotImplementedError(); } /** @@ -46,7 +48,7 @@ class TimerInterface { * @returns {Promise} resolved once updated and scheduled */ update() { - throw new Error('Not implemented'); + throw new NotImplementedError(); } /** @@ -55,7 +57,7 @@ class TimerInterface { * @returns {Promise} resolved once stopped */ stop() { - throw new Error('Not implemented'); + throw new NotImplementedError(); } } diff --git a/src/lib/utils/tracer.js b/src/lib/utils/tracer.js index 6fd3860e..c5790c09 100644 --- a/src/lib/utils/tracer.js +++ b/src/lib/utils/tracer.js @@ -39,7 +39,6 @@ let TRACER_ID = 0; * cache: Array, * dataActions: Array, * fd: number, - * fs: object * lastWriteTimestamp: number, * state: string, * taskPromise: Promise, @@ -134,13 +133,11 @@ class Tracer { PRIVATES.set(this, { cache: [], dataActions: [], - fs: options.fs || utils.fs, lastWriteTimestamp: Date.now(), taskPromise: Promise.resolve() }); - /** - * read-only properties below that should never be changed - */ + + /** define static read-only props that should not be overriden */ Object.defineProperties(this, { disabled: { get() { @@ -217,16 +214,17 @@ class Tracer { * * @async * @public - * @returns {Promise} resolved once tracer stopped + * @returns {Error | undefined} once tracer stopped */ - stop() { - return new Promise((resolve, reject) => { - this.logger.debug(`Stopping stream to file '${this.path}'`); - PRIVATES.get(this).state = STATES.DISABLED; - tpm.safeClose.call(this) - .then(resolve, reject); - }) - .catch((err) => err); + async stop() { + this.logger.debug(`Stopping stream to file '${this.path}'`); + PRIVATES.get(this).state = STATES.DISABLED; + try { + await tpm.safeClose.call(this); + } catch (error) { + return error; + } + return undefined; } /** @@ -234,13 +232,10 @@ class Tracer { * * @async * @public - * @returns {Promise} resolved once tracer suspended + * @returns {void} once tracer suspended */ - suspend() { - return new Promise((resolve, reject) => { - tpm.suspend.call(this, true) - .then(resolve, reject); - }); + async suspend() { + await tpm.suspend.call(this, true); } /** @@ -252,14 +247,15 @@ class Tracer { * @public * @param {any} data - data to write to tracer (should be JSON serializable) * - * @returns {Promise} resolved once data written to file + * @returns {Error | undefined} once data written to file */ - write(data) { - return new Promise((resolve, reject) => { - tpm.write.call(this, data) - .then(resolve, reject); - }) - .catch((err) => err); + async write(data) { + try { + await tpm.write.call(this, data); + } catch (error) { + return error; + } + return undefined; } } @@ -325,25 +321,21 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved once file closed + * @returns {void} once file closed */ - close() { - return new Promise((resolve, reject) => { - if (typeof this.fd === 'undefined') { - resolve(); - } else { - this.logger.debug(`Closing stream to file '${this.path}'`); - const privates = PRIVATES.get(this); - privates.fs.close(this.fd) - .catch((closeErr) => { - this.logger.debugException(`Unable to close file '${this.path}' with fd '${this.fd}'`, closeErr); - }) - .then(() => { - privates.fd = undefined; - }) - .then(resolve, reject); + async close() { + if (typeof this.fd !== 'undefined') { + this.logger.debug(`Closing stream to file '${this.path}'`); + const privates = PRIVATES.get(this); + + try { + await utils.fs.close(this.fd); + } catch (closeErr) { + this.logger.debugException(`Unable to close file '${this.path}' with fd '${this.fd}'`, closeErr); + } finally { + privates.fd = undefined; } - }); + } }, /** @@ -382,25 +374,27 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved once destination directory created + * @returns {void} once destination directory created */ - mkdir() { - return new Promise((resolve, reject) => { - const baseDir = pathUtil.dirname(this.path); - const fs = PRIVATES.get(this).fs; - - fs.access(baseDir, (fs.constants || fs).R_OK) - .then(() => true, () => false) - .then((exist) => { - if (exist) { - return Promise.resolve(); - } - this.logger.debug(`Creating dir '${baseDir}'`); - return fs.mkdir(baseDir); - }) - .catch((mkdirError) => (mkdirError.code === 'EEXIST' ? Promise.resolve() : Promise.reject(mkdirError))) - .then(resolve, reject); - }); + async mkdir() { + const baseDir = pathUtil.dirname(this.path); + + try { + await utils.fs.access(baseDir, utils.fs.constants.R_OK); + return; + } catch (error) { + this.logger.debugException('fs.access error:', error); + } + + this.logger.debug(`Creating dir '${baseDir}'`); + try { + await utils.fs.mkdir(baseDir); + } catch (error) { + if (error.code === 'EEXIST') { + return; + } + throw error; + } }, /** @@ -408,22 +402,14 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} Promise resolved when new stream created or exists already + * @returns {void} once new stream created or exists already */ - open() { - return new Promise((resolve, reject) => { - if (typeof this.fd !== 'undefined') { - resolve(); - } else { - this.logger.debug(`Creating file '${this.path}'`); - tpm.mkdir.call(this) - .then(() => PRIVATES.get(this).fs.open(this.path, 'a+')) - .then((openRet) => { - PRIVATES.get(this).fd = openRet[0]; - }) - .then(resolve, reject); - } - }); + async open() { + if (typeof this.fd === 'undefined') { + this.logger.debug(`Creating file '${this.path}'`); + await tpm.mkdir.call(this); + PRIVATES.get(this).fd = await utils.fs.open(this.path, 'a+'); + } }, /** @@ -455,26 +441,15 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved when data was read + * @returns {string} once data was read */ - read() { - return new Promise((resolve, reject) => { - const fs = PRIVATES.get(this).fs; - fs.fstat(this.fd) - .then((statRet) => { - const fileSize = statRet[0].size; - if (!fileSize) { - return Promise.resolve(''); - } - return fs.read(this.fd, Buffer.alloc(fileSize), 0, fileSize, 0) - .then((readRet) => { - const buffer = readRet[1]; - const bytesRead = readRet[0]; - return buffer.slice(0, bytesRead).toString(this.encoding); - }); - }) - .then(resolve, reject); - }); + async read() { + const fileSize = (await utils.fs.fstat(this.fd)).size; + if (!fileSize) { + return ''; + } + const { buffer, bytesRead } = await utils.fs.read(this.fd, Buffer.alloc(fileSize), 0, fileSize, 0); + return buffer.slice(0, bytesRead).toString(this.encoding); }, /** @@ -495,32 +470,30 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved once file closed + * @returns {void} once file closed */ - safeClose() { - return new Promise((resolve, reject) => { - if (this.disabled || this.suspended) { - const privates = PRIVATES.get(this); - - if (typeof privates.timer !== 'undefined') { - privates.timer.stop() - .then(() => { - this.logger.debug('Inactivity timer deactivated.'); - privates.timer = undefined; - }) - .catch((err) => this.logger.debugException('Error on attempt to deactivate the inactivity timer:', err)); - } + async safeClose() { + if (this.disabled || this.suspended) { + const privates = PRIVATES.get(this); - this.logger.debug(`Stopping stream to file '${this.path}'`); - // schedule file closing right after last scheduled writing attempt - // but data that awaits for writing will be lost - privates.taskPromise = privates.taskPromise - .then(tpm.close.bind(this)) - .then(resolve, reject); - } else { - resolve(); + if (typeof privates.timer !== 'undefined') { + privates.timer.stop() + .then(() => { + this.logger.debug('Inactivity timer deactivated.'); + privates.timer = undefined; + }) + .catch((err) => this.logger.debugException('Error on attempt to deactivate the inactivity timer:', err)); } - }); + + this.logger.debug(`Stopping stream to file '${this.path}'`); + + // schedule file closing right after last scheduled writing attempt + // but data that awaits for writing will be lost + privates.taskPromise = privates.taskPromise + .then(tpm.close.bind(this)); + + await privates.taskPromise; + } }, /** @@ -528,40 +501,37 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved once timer configured + * @returns {void} once timer configured */ - setupSuspendTimeout() { - return new Promise((resolve, reject) => { - const privates = PRIVATES.get(this); - /** - * set interval to check when last attempt to write data was made. - * Do not care to be accurate here, this is debug tool only. - * Main idea of this approach is to close a file descriptor to save resources - * when last attempt to write data was made more than 15m (by default) - * minutes ago. - */ - if (!this.disabled) { - PRIVATES.get(this).state = STATES.READY; - } - if (this.inactivityTimeout && typeof privates.timer === 'undefined') { - privates.timer = new timers.BasicTimer(tpm.suspend.bind(this), { - abortOnFailure: false, - intervalInS: this.inactivityTimeout - }); - privates.timer.start() - .then(() => this.logger.debug(`Inactivity timeout set to ${this.inactivityTimeout} s.`)) - .catch((err) => { - this.logger.debugException('Unable to start inactivity timer:', err); - - const timer = privates.timer; - privates.timer = undefined; - return timer.stop(); - }) - .then(resolve, reject); - } else { - resolve(); + async setupSuspendTimeout() { + const privates = PRIVATES.get(this); + /** + * set interval to check when last attempt to write data was made. + * Do not care to be accurate here, this is debug tool only. + * Main idea of this approach is to close a file descriptor to save resources + * when last attempt to write data was made more than 15m (by default) + * minutes ago. + */ + if (!this.disabled) { + PRIVATES.get(this).state = STATES.READY; + } + if (this.inactivityTimeout && typeof privates.timer === 'undefined') { + privates.timer = new timers.BasicTimer(tpm.suspend.bind(this), { + abortOnFailure: false, + intervalInS: this.inactivityTimeout + }); + + try { + await privates.timer.start(); + this.logger.debug(`Inactivity timeout set to ${this.inactivityTimeout} s.`); + } catch (error) { + this.logger.debugException('Unable to start inactivity timer:', error); + + const timer = privates.timer; + privates.timer = undefined; + await timer.stop(); } - }); + } }, /** @@ -571,27 +541,22 @@ const tpm = { * @this Tracer * @param {boolean} [ignoreTimeout = false] - ignore inactivity timeout check * - * @returns {Promise} resolved once tracer suspended + * @returns {void} once tracer suspended */ - suspend(ignoreTimeout) { - return new Promise((resolve, reject) => { - const privates = PRIVATES.get(this); - const delta = (Date.now() - privates.lastWriteTimestamp) / 1000.0; // in seconds - - if (this.disabled) { - resolve(); - } else if (this.inactivityTimeout <= delta || ignoreTimeout) { - if (ignoreTimeout) { - this.logger.debug(`Suspending stream to file '${this.path}' (.suspend())`); - } else { - this.logger.debug(`Suspending stream to file '${this.path}' due inactivity (${this.inactivityTimeout} s. timeout)`); - } + async suspend(ignoreTimeout) { + const privates = PRIVATES.get(this); + const idleTime = (Date.now() - privates.lastWriteTimestamp) / 1000.0; // in seconds - PRIVATES.get(this).state = STATES.SUSPENDED; - tpm.safeClose.call(this) - .then(resolve, reject); + if (!this.disabled && (this.inactivityTimeout <= idleTime || ignoreTimeout)) { + if (ignoreTimeout) { + this.logger.debug(`Suspending stream to file '${this.path}' (.suspend())`); + } else { + this.logger.debug(`Suspending stream to file '${this.path}' due inactivity (${this.inactivityTimeout} s. timeout)`); } - }); + + PRIVATES.get(this).state = STATES.SUSPENDED; + await tpm.safeClose.call(this); + } }, /** @@ -599,44 +564,42 @@ const tpm = { * * @async * @this Tracer - * @returns {Promise} resolved once data written to file + * @returns {void} once data written to file */ - tryWriteData() { - return new Promise((resolve, reject) => { - const privates = PRIVATES.get(this); + async tryWriteData() { + const privates = PRIVATES.get(this); + const writePromise = privates.writePromise; - const writePromise = privates.writePromise; - tpm.setupSuspendTimeout.call(this) - .then(tpm.open.bind(this)) - .then(() => { - // don't need to read and parse data when cache has a lot of data already - if (privates.cache.length >= this.maxRecords) { - return []; - } - return tpm.read.call(this) - .then(tpm.parse.bind(this)); - }) - .then((readData) => { - privates.writePromise = null; - return tpm.mergeAndResetCache.call(this, readData); - }) - .then((data) => utils.stringify(data, true)) - .then((dataToWrite) => privates.fs.ftruncate(this.fd, 0) - .then(() => privates.fs.write(this.fd, dataToWrite, 0, this.encoding))) - .catch((err) => { - // close trace, lost data - this.logger.debugException(`Unable to write data to '${this.path}'`, err); - return tpm.close.call(this); // should not reject - }) - .then(() => { - if (writePromise === privates.writePromise) { - // error might happened before - privates.writePromise = null; - } - return this.disabled ? tpm.close.call(this) : Promise.resolve(); - }) - .then(resolve, reject); - }); + try { + await tpm.setupSuspendTimeout.call(this); + await tpm.open.call(this); + + let readData; + // don't need to read and parse data when cache has a lot of data already + if (privates.cache.length >= this.maxRecords) { + readData = []; + } else { + readData = tpm.parse.call(this, await tpm.read.call(this)); + } + + privates.writePromise = null; + + const data = utils.stringify(tpm.mergeAndResetCache.call(this, readData), true); + + await utils.fs.ftruncate(this.fd, 0); + await utils.fs.write(this.fd, data, 0, this.encoding); + } catch (error) { + // close trace, lost data + this.logger.debugException(`Unable to write data to '${this.path}'`, error); + await tpm.close.call(this); + } + if (writePromise === privates.writePromise) { + // error might happened before + privates.writePromise = null; + } + if (this.disabled) { + await tpm.close.call(this); + } }, /** @@ -646,29 +609,25 @@ const tpm = { * @this Tracer * @param {any} data - data to write * - * @returns {Promise} resolved once data written to file + * @returns {void} once data written to file */ - write(data) { - return new Promise((resolve, reject) => { - if (this.disabled) { - resolve(); - } else { - PRIVATES.get(this).lastWriteTimestamp = Date.now(); - tpm.cacheData.call(this, data); - - // check if cache was flushed to file already or not - const privates = PRIVATES.get(this); - if (!privates.writePromise) { - // add current attempt to main task queue - privates.taskPromise = privates.taskPromise - .then(tpm.tryWriteData.bind(this)); - // re-use current 'write' promise as separate queue - // to be able to batch multiple 'write' requests into one - privates.writePromise = privates.taskPromise; - } - privates.writePromise.then(resolve, reject); + async write(data) { + if (!this.disabled) { + PRIVATES.get(this).lastWriteTimestamp = Date.now(); + tpm.cacheData.call(this, data); + + // check if cache was flushed to file already or not + const privates = PRIVATES.get(this); + if (!privates.writePromise) { + // add current attempt to main task queue + privates.taskPromise = privates.taskPromise + .then(tpm.tryWriteData.bind(this)); + // re-use current 'write' promise as separate queue + // to be able to batch multiple 'write' requests into one + privates.writePromise = privates.taskPromise; } - }); + await privates.writePromise; + } } }; @@ -708,7 +667,6 @@ module.exports = { * @typedef TracerOptions * @type {object} * @property {string} [encoding = 'utf8'] - data encoding - * @property {object} [fs] - FS module, by default 'fs' from './misc' * @property {number} [inactivityTimeout = 900] - inactivity timeout (in s.) after which Tracer will be suspended * @property {number} [maxRecords = 10] - max records to store */ diff --git a/src/nodejs/restWorker.js b/src/nodejs/restWorker.js index 34b6fac1..58cc358e 100644 --- a/src/nodejs/restWorker.js +++ b/src/nodejs/restWorker.js @@ -15,6 +15,7 @@ */ /* jshint ignore: start */ +/* eslint-disable no-unused-expressions */ 'use strict'; @@ -25,23 +26,29 @@ const appInfo = require('../lib/appInfo'); const logger = require('../lib/logger'); const util = require('../lib/utils/misc'); -const ActivityRecorder = require('../lib/activityRecorder'); +const ApplicationEvents = require('../lib/appEvents'); +const configWorker = require('../lib/config'); +const Consumers = require('../lib/consumers'); const DataPipeline = require('../lib/dataPipeline'); -const EventListener = require('../lib/eventListener'); +const DeclarationHistory = require('../lib/declarationHistory'); const deviceUtil = require('../lib/utils/device'); -const retryPromise = require('../lib/utils/promise').retry; -const persistentStorage = require('../lib/persistentStorage'); -const configWorker = require('../lib/config'); -const requestRouter = require('../lib/requestHandlers/router'); +const EventEmitter = require('../lib/utils/eventEmitter'); +const EventListener = require('../lib/eventListener'); +const IHealthService = require('../lib/ihealth'); +const promiseUtil = require('../lib/utils/promise'); +const PullConsumers = require('../lib/pullConsumers'); const ResourceMonitor = require('../lib/resourceMonitor'); +const RESTAPIService = require('../lib/restAPI'); const RuntimeConfig = require('../lib/runtimeConfig'); -const SystemPoller = require('../lib/systemPoller'); +const StorageService = require('../lib/storage'); +const SystemPollerService = require('../lib/systemPoller'); +const TeemReporter = require('../lib/teemReporter'); + +/** + * @module restWorker + */ const configListenerModulesToLoad = [ - '../lib/consumers', - '../lib/pullConsumers', - '../lib/ihealth', - '../lib/requestHandlers/connections', '../lib/tracerManager.js' ]; @@ -60,11 +67,21 @@ configListenerModulesToLoad.forEach((module) => { * application state/stop. See more details on F5 DevCentral. * * @class + * + * @listens *.requestHandler.created */ function RestWorker() { this.WORKER_URI_PATH = 'shared/telemetry'; this.isPassThrough = true; this.isPublic = true; + + // TS properties + this.appEvents = new ApplicationEvents(); + this.logger = logger.getChild('restWorker'); + this.ee = new EventEmitter(); + this.ee.logger = this.logger; + this._requestHandlers = []; + this.services = []; } /** @@ -101,7 +118,7 @@ RestWorker.prototype.onStartCompleted = function (success, failure, state, errMs this._initializeApplication(success, failure); } catch (err) { const msg = `onStartCompleted error: ${err}`; - logger.exception(msg, err); + this.logger.exception(msg, err); failure(msg); } }; @@ -116,62 +133,60 @@ RestWorker.prototype.onStartCompleted = function (success, failure, state, errMs // eslint-disable-next-line no-unused-vars RestWorker.prototype._initializeApplication = function (success, failure) { // Log system info on service start - logger.info(`Application version: ${appInfo.fullVersion}`); - logger.info(`Node version: ${process.version}`); - - // register REST endpoints - this.router = requestRouter; - this.router.registerAllHandlers(false); - - this.activityRecorder = new ActivityRecorder(); - this.activityRecorder.recordDeclarationActivity(configWorker); + this.logger.info(`Application version: ${appInfo.fullVersion}`); + this.logger.info(`Node version: ${process.version}`); - const appCtx = { - configMgr: configWorker, - resourceMonitor: new ResourceMonitor(), - runtimeConfig: new RuntimeConfig() - }; + // order matter :-) + this.services.push( + EventListener, + new Consumers(), + new PullConsumers(), + new SystemPollerService(), + new StorageService(), + new RuntimeConfig(), + new ResourceMonitor(), + new RESTAPIService(this.WORKER_URI_PATH), + new TeemReporter(), + new DeclarationHistory(), + new IHealthService(), + new DataPipeline(), + configWorker + ); - appCtx.resourceMonitor.initialize(appCtx); - appCtx.runtimeConfig.initialize(appCtx); + this.services.forEach((service) => { + if (service instanceof StorageService) { + service.initialize(this.appEvents, this); + } else { + service.initialize(this.appEvents); + } + }); - DataPipeline.initialize(appCtx); - EventListener.initialize(appCtx); - SystemPoller.initialize(appCtx); + this.initialize(this.appEvents); // configure global socket maximum http.globalAgent.maxSockets = 5; https.globalAgent.maxSockets = 5; - appCtx.runtimeConfig.start() - .then(() => appCtx.resourceMonitor.start()) - .then(() => { - // try to load pre-existing configuration - const ps = persistentStorage.persistentStorage; - // only RestStorage is supported for now - ps.storage = new persistentStorage.RestStorage(this); - return ps.load(); - }) - .then((loadedState) => { - logger.debug(`Loaded state ${util.stringify(loadedState)}`); - }) + promiseUtil.loopForEach(this.services, (service) => service.start && service.start()) .then(() => configWorker.load()) .then(() => success()) .catch((err) => { - logger.exception('Startup Failed', err); + this.logger.exception('Startup Failed', err); failure(); }); // Gather info about host device. Running it as decoupled process // to do not slow down application startup due REST API or other // service may be not started yet. - retryPromise(() => deviceUtil.gatherHostDeviceInfo(), { maxTries: 100, delay: 30 }) + promiseUtil.retry(() => deviceUtil.gatherHostDeviceInfo(), { maxTries: 100, delay: 30 }) .then(() => { - logger.info('Host Device Info gathered'); + this.logger.info('Host Device Info gathered'); }) .catch((err) => { - logger.exception('Unable to gather Host Device Info', err); + this.logger.exception('Unable to gather Host Device Info', err); }); + + this._onAppExitOff = util.onApplicationExit(() => this.tsDestroy()); }; /** @@ -179,10 +194,10 @@ RestWorker.prototype._initializeApplication = function (success, failure) { * * @param {Object} restOperation * - * @returns {void} + * @returns {Promise} resolved once request processed */ RestWorker.prototype.onDelete = function (restOperation) { - this.onPost(restOperation); + return this.onPost(restOperation); }; /** @@ -190,10 +205,10 @@ RestWorker.prototype.onDelete = function (restOperation) { * * @param {Object} restOperation * - * @returns {void} + * @returns {Promise} resolved once request processed */ RestWorker.prototype.onGet = function (restOperation) { - this.onPost(restOperation); + return this.onPost(restOperation); }; /** @@ -201,10 +216,76 @@ RestWorker.prototype.onGet = function (restOperation) { * * @param {Object} restOperation * - * @returns {void} + * @returns {Promise} resolved once request processed */ -RestWorker.prototype.onPost = function (restOperation) { - this.router.processRestOperation(restOperation, this.WORKER_URI_PATH); +RestWorker.prototype.onPost = async function (restOperation) { + if (this._requestHandlers.length > 0) { + try { + await (this._requestHandlers[0].handler(restOperation)); + } catch (reqError) { + requestError.call(this, restOperation, 500, 'Internal Server Error', reqError); + } + } else { + requestError.call(this, restOperation, 503, 'Service Unavailable', new Error('No request handlers to process request!')); + } }; +/** @param {ApplicationEvents} appEvents - application events */ +RestWorker.prototype.initialize = function (appEvents) { + appEvents.on('*.requestHandler.created', (handler, cb) => { + const item = { handler }; + this._requestHandlers.splice(0, 0, item); + + cb && cb(() => { + const idx = this._requestHandlers.indexOf(item); + (idx !== -1) && this._requestHandlers.splice(idx, 1); + }); + }); +}; + +/** + * Destroy instance and its services + * + * @returns {Promise} resolved once all serivces destroyed + */ +RestWorker.prototype.tsDestroy = function () { + this._onAppExitOff && this._onAppExitOff(); + this._requestHandlers = []; + + return promiseUtil.loopForEach( + this.services, (service) => service.destroy && service.destroy() + .catch((err) => this.logger.exception(`Unable to destroy service "${service.constructor.name}"`, err)) + ) + .then(() => { + this.services = []; + this.appEvents.stop(); + + this.logger.warning('All services destroyed!'); + }); +}; + +/** + * @param {RestOperation} restOperation + * @param {integer} code + * @param {string} message + * @param {Error} error + */ +function requestError(restOperation, code, message, error) { + this.logger.exception('Uncaught exception on attempt to process REST API request', error); + restOperation.setStatusCode(code); + restOperation.setContentType('application/json'); + restOperation.setBody({ code, message }); + restOperation.complete(); +} + module.exports = RestWorker; + +/** + * @event *.requestHandler.created + * @param {function:Promise} handler - request handler that returns Promise resovled once request processed + * @param {function(function)} unregCb - callback to pass back an deregister function for the request handler + */ +/** + * @typedef RestOperation + * @type {Object} + */ diff --git a/src/schema/1.36.0/actions_schema.json b/src/schema/1.36.0/actions_schema.json new file mode 100644 index 00000000..c658439b --- /dev/null +++ b/src/schema/1.36.0/actions_schema.json @@ -0,0 +1,187 @@ +{ + "$id": "actions_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Actions schema", + "description": "", + "type": "object", + "definitions": { + "baseActionsChain": { + "title": "Chain of Actions", + "description": "Actions to be performed on the data.", + "type": "array", + "items": { + "$ref": "#/definitions/baseActionObject" + } + }, + "baseActionObject": { + "title": "Base Action object", + "description": "Base object to build actions.", + "type": "object", + "properties": { + "enable": { + "title": "Enable", + "description": "Whether to enable this action in the declaration or not.", + "type": "boolean", + "default": true + } + } + }, + "baseConditionalActionObject": { + "title": "Base Action object with support for conditional statements", + "description": "Base Action object with support for conditional statements.", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/baseActionObject" }, + { + "anyOf": [ + { + "properties": { + "ifAllMatch": { + "title": "If All Match", + "description": "The conditions that will be checked against. All must be true.", + "type": "object", + "additionalProperties": true + } + }, + "not": { "required": ["ifAnyMatch"] } + }, + { + "properties": { + "ifAnyMatch": { + "title": "If Any Match", + "description": "An array of ifAllMatch objects. Any individual ifAllMatch object may match, but each condition within an ifAllMatch object must be true", + "type": "array" + } + }, + "not": { "required": ["ifAllMatch"] } + } + ] + } + ] + }, + "subLocation": { + "title": "Location", + "description": "Used to specify a location in TS data. Use boolean type with value true to specify the location.", + "oneOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/subLocation" + } + } + ] + }, + "locations": { + "title": "Location", + "description": "The location(s) to apply the action.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/subLocation" + } + }, + "setTagAction": { + "title": "setTag Action", + "description": "Action to assign a tag(s) to particular or default location", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/baseConditionalActionObject" }, + { + "properties": { + "setTag": { + "title": "Set Tag", + "description": "The tag values to be added.", + "type": "object", + "additionalProperties": true + }, + "locations": { + "title": "Location", + "description": "The location(s) to apply the action.", + "allOf": [{ "$ref": "#/definitions/locations" }] + }, + "enable": {}, + "ifAllMatch": {}, + "ifAnyMatch": {} + }, + "additionalProperties": false, + "required": ["setTag"] + } + ] + }, + "includeDataAction": { + "title": "includeData Action", + "description": "Action to specify data fields to include in the output", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/baseConditionalActionObject" }, + { + "properties": { + "includeData": { + "title": "Include Data", + "description": "The data fields to include in the output", + "type": "object", + "additionalProperties": false + }, + "locations": { + "title": "Location", + "description": "The location(s) to apply the action.", + "allOf": [{ "$ref": "#/definitions/locations" }] + }, + "enable": {}, + "ifAllMatch": {}, + "ifAnyMatch": {} + }, + "additionalProperties": false, + "required": ["includeData", "locations"] + } + ] + }, + "excludeDataAction": { + "title": "excludeData Action", + "description": "Action to specify data fields to exclude form the output", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/baseConditionalActionObject" }, + { + "properties": { + "excludeData": { + "title": "Exclude Data", + "description": "The data fields to exclude from the output", + "type": "object", + "additionalProperties": false + }, + "locations": { + "title": "Location", + "description": "The location(s) to apply the action.", + "allOf": [{ "$ref": "#/definitions/locations" }] + }, + "enable": {}, + "ifAllMatch": {}, + "ifAnyMatch": {} + }, + "additionalProperties": false, + "required": ["excludeData", "locations"] + } + ] + }, + "inputDataStreamActionsChain": { + "title": "", + "description": "", + "allOf": [ + { "$ref": "#/definitions/baseActionsChain" }, + { + "items": { + "oneOf": [ + { "$ref": "#/definitions/excludeDataAction" }, + { "$ref": "#/definitions/includeDataAction" }, + { "$ref": "#/definitions/setTagAction" } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/src/schema/1.36.0/base_schema.json b/src/schema/1.36.0/base_schema.json new file mode 100644 index 00000000..5f319c2c --- /dev/null +++ b/src/schema/1.36.0/base_schema.json @@ -0,0 +1,310 @@ +{ + "$id": "base_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming", + "description": "", + "type": "object", + "definitions": { + "enable": { + "title": "Enable", + "description": "This property can be used to enable/disable the poller/listener" , + "type": "boolean" + }, + "trace": { + "title": "Trace", + "description": "Enables data dumping to file. Boolean uses pre-defined file location, however value could be a string which contains path to a specific file instead" , + "minLength": 1, + "type": ["boolean", "string"] + }, + "traceConfig": { + "title": "Trace (v2)", + "description": "Enables data dumping to file. Boolean uses pre-defined file location, however value could be a string which contains path to a specific file instead", + "type": "object", + "properties": { + "type": { + "title": "Trace type", + "description": "Trace type - output data or input data", + "type": "string", + "enum": ["output", "input"] + }, + "path": { + "title": "Path to trace file", + "description": "Path to trace file to write data to", + "type": "string", + "minLength": 1 + } + }, + "required": ["type"] + }, + "traceV2": { + "title": "Trace (v2)", + "description": "Enables data dumping to file. Boolean uses pre-defined file location, however value could be a string which contains path to a specific file instead", + "oneOf": [ + { "$ref": "#/definitions/traceConfig" }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItemProperties": ["type"], + "items": { + "allOf": [{ + "$ref": "#/definitions/traceConfig" + }] + } + } + ] + }, + "secret": { + "title": "Passphrase (secret)", + "description": "" , + "type": "object", + "properties": { + "class": { + "title": "Class", + "description": "Telemetry streaming secret class", + "type": "string", + "enum": [ "Secret" ], + "default": "Secret" + }, + "cipherText": { + "title": "Cipher Text: this contains a secret to encrypt", + "type": "string" + }, + "environmentVar": { + "title": "Environment Variable: this contains the named env var where the secret resides", + "type": "string", + "minLength": 1 + }, + "protected": { + "$comment": "Meta property primarily used to determine if 'cipherText' needs to be encrypted", + "title": "Protected", + "type": "string", + "enum": [ "plainText", "plainBase64", "SecureVault" ], + "default": "plainText" + } + }, + "oneOf": [ + { "required": [ "cipherText" ] }, + { "required": [ "environmentVar" ] } + ], + "f5secret": true + }, + "username": { + "$comment": "Common field for username to use everywhere in scheme", + "title": "Username", + "type": "string", + "minLength": 1 + }, + "stringOrSecret": { + "allOf": [ + { + "if": { "type": "string" }, + "then": {}, + "else": {} + }, + { + "if": { "type": "object" }, + "then": { "$ref": "base_schema.json#/definitions/secret" }, + "else": {} + } + ] + }, + "constants": { + "title": "Constants", + "description": "" , + "type": "object", + "properties": { + "class": { + "title": "Class", + "description": "Telemetry streaming constants class", + "type": "string", + "enum": [ "Constants" ] + } + }, + "additionalProperties": true + }, + "tag": { + "$comment": "Defaults do not get applied for $ref objects, so place defaults alongside instead.", + "title": "Tag", + "description": "" , + "type": "object", + "properties": { + "tenant": { + "title": "Tenant tag", + "type": "string", + "minLength": 1 + }, + "application": { + "title": "Application tag", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + }, + "match": { + "$comment": "Defaults do not get applied for $ref objects, so place defaults alongside instead.", + "title": "Pattern to filter data", + "description": "", + "type": "string" + }, + "enableHostConnectivityCheck": { + "$comment": "This property can be used to enable/disable the host connectivity check in configurations where this is in effect", + "title": "Host", + "description": "" , + "type": "boolean" + }, + "allowSelfSignedCert": { + "$comment": "This property can be used by consumers, system pollers to enable/disable SSL Cert check", + "title": "Allow Self-Signed Certificate", + "description": "" , + "type": "boolean" + }, + "host": { + "$comment": "This property can be used by consumers, system pollers", + "title": "Host", + "description": "" , + "type": "string", + "minLength": 1, + "anyOf": [ + { "format": "ipv4" }, + { "format": "ipv6" }, + { "format": "hostname" } + ], + "hostConnectivityCheck": true + }, + "port": { + "title": "Port", + "description": "" , + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "protocol": { + "title": "Protocol", + "description": "" , + "type": "string", + "enum": [ "http", "https" ] + }, + "proxy": { + "title": "Proxy Configuration", + "description": "", + "type": "object", + "dependencies": { + "passphrase": [ "username" ] + }, + "required": [ "host" ], + "properties": { + "host": { + "$ref": "#/definitions/host" + }, + "port": { + "default": 80, + "allOf": [ + { + "$ref": "#/definitions/port" + } + ] + }, + "protocol": { + "default": "http", + "allOf": [ + { + "$ref": "#/definitions/protocol" + } + ] + }, + "enableHostConnectivityCheck": { + "$ref": "#/definitions/enableHostConnectivityCheck" + }, + "allowSelfSignedCert": { + "$ref": "#/definitions/allowSelfSignedCert" + }, + "username": { + "$ref": "#/definitions/username" + }, + "passphrase": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + } + }, + "properties": { + "class": { + "title": "Class", + "description": "Telemetry streaming top level class", + "type": "string", + "enum": [ "Telemetry" ] + }, + "schemaVersion": { + "title": "Schema version", + "description": "Version of ADC Declaration schema this declaration uses", + "type": "string", + "$comment": "IMPORTANT: In enum array, please put current schema version first, oldest-supported version last. Keep enum array sorted most-recent-first.", + "enum": [ "1.36.0", "1.35.0", "1.34.0", "1.33.0", "1.32.0", "1.31.0", "1.30.0", "1.29.0", "1.28.0", "1.27.1", "1.27.0", "1.26.0", "1.25.0", "1.24.0", "1.23.0", "1.22.0", "1.21.0", "1.20.1", "1.20.0", "1.19.0", "1.18.0", "1.17.0", "1.16.0", "1.15.0", "1.14.0", "1.13.0", "1.12.0", "1.11.0", "1.10.0", "1.9.0", "1.8.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.3.0", "1.2.0", "1.1.0", "1.0.0", "0.9.0" ], + "default": "1.36.0" + }, + "$schema": { + "title": "Schema", + "description": "", + "type": "string" + } + }, + "additionalProperties": { + "$comment": "AJV does not resolve defaults inside oneOf/anyOf, so instead use allOf. Any schema refs should also use allOf with an if/then/else on class", + "properties": { + "class": { + "title": "Class", + "type": "string", + "enum": [ + "Telemetry_System", + "Telemetry_System_Poller", + "Telemetry_Listener", + "Telemetry_Consumer", + "Telemetry_Pull_Consumer", + "Telemetry_iHealth_Poller", + "Telemetry_Endpoints", + "Telemetry_Namespace", + "Controls", + "Shared" + ] + } + }, + "allOf": [ + { + "$ref": "system_schema.json#" + }, + { + "$ref": "system_poller_schema.json#" + }, + { + "$ref": "listener_schema.json#" + }, + { + "$ref": "consumer_schema.json#" + }, + { + "$ref": "pull_consumer_schema.json#" + }, + { + "$ref": "ihealth_poller_schema.json#" + }, + { + "$ref": "endpoints_schema.json#" + }, + { + "$ref": "controls_schema.json#" + }, + { + "$ref": "shared_schema.json#" + }, + { + "$ref": "namespace_schema.json#" + } + ] + }, + "required": [ + "class" + ] +} diff --git a/src/schema/1.36.0/common_schema.json b/src/schema/1.36.0/common_schema.json new file mode 100644 index 00000000..dfb546bf --- /dev/null +++ b/src/schema/1.36.0/common_schema.json @@ -0,0 +1,92 @@ +{ + "$id": "common_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Common Objects Schema", + "description": "", + "type": "object", + "definitions": { + "additionalOptions": { + "$comment": "Additional custom options (e.g. feature flags for testing)", + "title": "Additional custom options (Target Object/Class Dependent)", + "description": "Additional custom options for use by target class. Refer to corresponding class schema for acceptable keys and values.", + "type": "array", + "items": { + "properties": { + "name": { + "description": "Name of the option", + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "value": { + "description": "Value of the option", + "minLength": 1, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "allOf": [ + { + "f5expand": true + }, + { + "$ref": "base_schema.json#/definitions/stringOrSecret" + } + ] + } + ] + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "httpAgentOptions": { + "title": "Http Agent Options", + "description": "Set of additional options to customize http agent.", + "type": "array", + "items": { + "properties": { + "name": { + "enum": [ + "keepAlive", + "keepAliveMsecs", + "maxSockets", + "maxFreeSockets" + ] + } + }, + "allOf": [ + { + "if": { "properties": { "name": { "const": "keepAlive" } } }, + "then": { "properties": { "value": { "type": "boolean" } } } + }, + { + "if": { "properties": { "name": { "const": "keepAliveMsecs" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + }, + { + "if": { "properties": { "name": { "const": "maxFreeSockets" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + }, + { + "if": { "properties": { "name": { "const": "maxSockets" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + } + ] + }, + "minItems": 1 + } + } +} \ No newline at end of file diff --git a/src/schema/1.36.0/consumer_schema.json b/src/schema/1.36.0/consumer_schema.json new file mode 100644 index 00000000..daacb325 --- /dev/null +++ b/src/schema/1.36.0/consumer_schema.json @@ -0,0 +1,1485 @@ +{ + "$id": "consumer_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Consumer schema", + "description": "", + "type": "object", + "definitions": { + "jmesPathAction": { + "title": "JMESPath Action", + "description": "Will use a JMESPath expression to modify the incoming data payload", + "type": "object", + "allOf": [ + { "$ref": "actions_schema.json#/definitions/baseActionObject" }, + { + "properties": { + "JMESPath": { + "title": "JMESPath", + "description": "Will use a JMESPath expression to modify the incoming data payload", + "type": "object", + "additionalProperties": false + }, + "expression": { + "title": "Expression", + "description": "The JMESPath expression to be applied to the incoming data payload", + "type": "string", + "minLength": 1 + }, + "enable": {} + }, + "additionalProperties": false, + "required": ["JMESPath", "expression"] + } + ] + }, + "autoTaggingStatsd": { + "title": "Statsd auto tagging", + "description": "Will parse incoming payload for values to automatically add as tags.", + "type": "object", + "properties": { + "method": { + "title": "AutoTagging method", + "description": "AutoTagging method to use to fetch tags", + "type": "string", + "enum": ["sibling"] + } + }, + "additionalProperties": false, + "required": ["method"] + }, + "genericHttpActions": { + "title": "Actions", + "description": "Actions to be performed on the Generic HTTP Consumer.", + "allOf": [ + { "$ref": "actions_schema.json#/definitions/baseActionsChain" }, + { + "items": { + "oneOf": [ + { "$ref": "#/definitions/jmesPathAction" } + ] + } + } + ] + }, + "host": { + "$comment": "Required for certain consumers: standard property", + "title": "Host", + "description": "FQDN or IP address" , + "type": "string", + "minLength": 1, + "anyOf": [ + { "format": "ipv4" }, + { "format": "ipv6" }, + { "format": "hostname" } + ], + "hostConnectivityCheck": true + }, + "protocols": { + "$comment": "Required for certain consumers: standard property", + "title": "Protocols (all)", + "description": "" , + "type": "string", + "enum": [ "https", "http", "tcp", "udp", "binaryTcpTls", "binaryTcp" ] + }, + "port": { + "$comment": "Required for certain consumers: standard property", + "title": "Port", + "description": "" , + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "path": { + "$comment": "Required for certain consumers: standard property", + "title": "Path", + "description": "Path to post data to", + "type": ["string", "object"], + "minLength": 1, + "f5expand": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/stringOrSecret" + } + ] + }, + "method": { + "$comment": "Required for certain consumers: standard property", + "title": "Method", + "description": "HTTP method to use (limited to sensical choices)" , + "type": "string", + "enum": [ "POST", "GET", "PUT" ] + }, + "headers": { + "$comment": "Required for certain consumers: standard property", + "title": "Headers", + "description": "HTTP headers to use" , + "type": "array", + "items": { + "properties": { + "name": { + "description": "Name of this header", + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "value": { + "description": "Value of this header", + "type": ["string", "object"], + "f5expand": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/stringOrSecret" + } + ] + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + } + }, + "customOpts": { + "$comment": "Required for certain consumers: standard property", + "title": "Custom Opts (Client Library Dependent)", + "description": "Additional options for use by consumer client library. Refer to corresponding consumer lib documentation for acceptable keys and values." , + "type": "array", + "allOf": [ { "$ref": "common_schema.json#/definitions/additionalOptions" } ] + }, + "format": { + "$comment": "Required for certain consumers: Splunk, Azure_Log_Analytics, Kafka", + "title": "Format (informs consumer additional formatting may be required)", + "description": "", + "type": "string" + }, + "username": { + "$comment": "Required for certain consumers: standard property", + "title": "Username", + "description": "" , + "minLength": 1, + "type": "string", + "f5expand": true + }, + "region": { + "$comment": "Required for certain consumers: AWS_CloudWatch, AWS_S3, Azure_Log_Analytics, Azure_App_Insights, DataDog", + "title": "Region", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "endpointUrl": { + "$comment": "Required for certain consumers: AWS_CloudWatch, AWS_S3", + "title": "endpoint url", + "description": "The full endpoint URL for service requests", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "bucket": { + "$comment": "Required for certain consumers: AWS_S3", + "title": "Bucket", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "maxAwsLogBatchSize": { + "$comment": "Required for certain consumers: AWS_CloudWatch", + "title": "Maximum Batch Size", + "description": "The maximum number of telemetry items to include in a payload to the ingestion endpoint", + "type": "integer", + "minimum": 1, + "default": 100, + "maximum": 10000 + }, + "logGroup": { + "$comment": "Required for certain consumers: AWS_CloudWatch", + "title": "Log Group", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "logStream": { + "$comment": "Required for certain consumers: AWS_CloudWatch", + "title": "Log Stream", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "metricNamespace": { + "$comment": "Required for certain consumers: AWS_CloudWatch", + "title": "Metric Namespace", + "description": "The namespace for the metrics", + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "metricPrefix": { + "$comment": "Required for certain consumers: DataDog", + "title": "Metric Prefix", + "description": "The string value(s) to use as a metric prefix", + "type": "array", + "minItems": 1, + "items": { + "allOf": [{ + "type": "string", + "f5expand": true, + "minLength": 1 + }] + } + }, + "workspaceId": { + "$comment": "Required for certain consumers: Azure_Log_Analytics", + "title": "Workspace ID", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "useManagedIdentity": { + "$comment": "Required for certain consumers: Azure_Log_Analytics and Azure_Application_Insights", + "title": "Use Managed Identity", + "description": "Determines whether to use Managed Identity to perform authorization for Azure services", + "type": "boolean", + "default": false + }, + "appInsightsResourceName": { + "$comment": "Required for certain consumers: Azure_Application_Insights", + "title": "Application Insights Resource Name (Pattern)", + "description": "Name filter used to determine which App Insights resource to send metrics to. If not provided, TS will send metrics to App Insights in the subscription in which the managed identity has permissions to", + "type": "string", + "minLength": 1 + }, + "instrumentationKey": { + "$comment": "Required for certain consumers: Azure_Application_Insights", + "title": "Instrumentation Key", + "description": "Used to determine which App Insights resource to send metrics to", + "anyOf": [ + { + "type": "string", + "f5expand": true, + "minLength": 1 + }, + { + "type":"array", + "items": { + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "minItems": 1 + } + ] + }, + "maxBatchIntervalMs": { + "$comment": "Required for certain consumers: Azure_Application_Insights", + "title": "Maximum Batch Interval (ms)", + "description": "The maximum amount of time to wait in milliseconds to for payload to reach maxBatchSize", + "type": "integer", + "minimum": 1000, + "default": 5000 + }, + "maxBatchSize": { + "$comment": "Required for certain consumers: Azure_Application_Insights", + "title": "Maximum Batch Size", + "description": "The maximum number of telemetry items to include in a payload to the ingestion endpoint", + "type": "integer", + "minimum": 1, + "default": 250 + }, + "topic": { + "$comment": "Required for certain consumers: Kafka", + "title": "Topic", + "description": "" , + "type": "string", + "f5expand": true + }, + "index": { + "$comment": "Required for certain consumers: ElasticSearch", + "title": "Index Name", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "apiVersion": { + "$comment": "Required for certain consumers: ElasticSearch", + "title": "API Version", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "dataType": { + "$comment": "Required for certain consumers: AWS_CloudWatch, ElasticSearch", + "title": "Data type", + "description": "" , + "type": "string", + "f5expand": true + }, + "authenticationProtocol": { + "$comment": "Required for certain consumers: Kafka", + "title": "Authentication Protocol", + "description": "" , + "type": "string", + "f5expand": true, + "enum": [ + "SASL-PLAIN", + "TLS", + "None" + ] + }, + "clientCertificate": { + "$comment": "Required for certain consumers: Kafka, Generic HTTP, OpenTelemetry_Exporter", + "title": "Client Certificate", + "description": "Certificate(s) to use when connecting to a secured endpoint.", + "type": "object", + "f5expand": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/secret" + } + ] + }, + "rootCertificate": { + "$comment": "Required for certain consumers: Kafka, Generic HTTP, OpenTelemetry_Exporter", + "title": "Root Certificate", + "description": "Certificate Authority root certificate, used to validate certificate chains.", + "type": "object", + "f5expand": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/secret" + } + ] + }, + "outputMode": { + "$comment": "Required for certain consumers: Generic HTTP", + "title": "output raw data flag", + "description": "Flag to request output of the raw data.", + "type": "string", + "enum": [ "processed", "raw" ] + }, + "projectId": { + "$comment": "Required for certain consumers: Google_Cloud_Monitoring", + "title": "Project ID", + "description": "The ID of the relevant project.", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "serviceEmail": { + "$comment": "Required for certain consumers: Google_Cloud_Monitoring, Google_Cloud_Logging", + "title": "Service Email", + "description": "The service email.", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "privateKeyId": { + "$comment": "Required for certain consumers when Service Account Token is not used: Google_Cloud_Monitoring, Google_Cloud_Logging", + "title": "Private Key ID", + "description": "The private key ID.", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "useServiceAccountToken": { + "$comment": "Used by certain consumers: Google_Cloud_Monitoring, Google_Cloud_Logging", + "title": "Use Service Account Token", + "description": "Determines whether to use Service Account Token to perform authorization for Google services", + "type": "boolean", + "default": false + }, + "logScope": { + "$comment": "Required for certain consumers: Google_Cloud_Logging", + "title": "Logging Scope Type", + "description": "" , + "enum": ["projects", "organizations", "billingAccounts", "folders"], + "f5expand": true + }, + "logScopeId": { + "$comment": "Required for certain consumers: Google_Cloud_Logging", + "title": "Logging Scope ID", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "logId": { + "$comment": "Required for certain consumers: Google_Cloud_Logging", + "title": "Logging ID", + "description": "" , + "type": "string", + "format": "regex", + "pattern": "^[a-zA-z0-9._-]+$", + "minLength": 1, + "f5expand": true + }, + "privateKey": { + "$comment": "Required for certain consumers: Kafka, Generic HTTP, OpenTelemetry_Exporter", + "title": "Private Key", + "description": "Private Key", + "type": "object", + "f5expand": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/secret" + } + ] + }, + "eventSchemaVersion": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Event Schema Version", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true, + "default": "1" + }, + "f5csTenantId": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "F5CS Tenant ID", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "f5csSensorId": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "F5CS Sensor ID", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "payloadSchemaNid": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Namespace ID for payloadSchema", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "serviceAccount": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Service Account", + "description": "Service Account to authentication" , + "type": "object", + "properties": { + "authType": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "SA Type", + "description": "" , + "type": "string", + "enum": ["google-auth" ] + }, + "type": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "SA Type", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "projectId": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Project Id", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "privateKeyId": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Private Key Id", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "privateKey": { + "$ref": "base_schema.json#/definitions/secret" + }, + "clientEmail": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Client Email", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "clientId": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Client Id", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "authUri": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Auth Uri", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "tokenUri": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Token Uri", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "authProviderX509CertUrl": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Auth Provider X509 Cert Url", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "clientX509CertUrl": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Client X509 Cert Url", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "authType": { "const": "google-auth" } } }, + "then": { + "required": [ + "type", + "projectId", + "privateKeyId", + "privateKey", + "clientEmail", + "clientId", + "authUri", + "tokenUri", + "authProviderX509CertUrl", + "clientX509CertUrl" + ] + }, + "else": {} + } + ] + }, + "targetAudience": { + "$comment": "Required for certain consumers: F5_Cloud", + "title": "Target Audience", + "description": "" , + "type": "string", + "minLength": 1, + "f5expand": true + }, + "useSSL": { + "$comment": "Required for certain consumers: F5_Cloud, OpenTelemetry_Exporter", + "title": "useSSL", + "description": "To decide if GRPC connection should use SSL and then it is secured" , + "type": "boolean", + "f5expand": true + }, + "compressionType": { + "$comment": "Required for certain consumers: DataDog, Splunk", + "title": "Data compression", + "description": "Whether or not to compress data and what compression to use before sending it to destination", + "type": "string", + "enum": ["none", "gzip"] + }, + "reportInstanceMetadata": { + "$comment": "Required for certain consumers: Google_Cloud_Monitoring, Google_Cloud_Logging", + "title": "Instance metadata reporting", + "description": "Enables instance metadata collection and reporting" , + "type": "boolean", + "f5expand": true + }, + "apiKey": { + "$comment": "Required for certain consumers: DataDog", + "title": "API key to use to push data", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "service": { + "$comment": "Required for certain consumers: DataDog", + "title": "The name of the service generating telemetry data", + "type": "string", + "minLength": 1, + "f5expand": true + }, + "convertBooleansToMetrics": { + "$comment": "Required for certain consumers: DataDog, Statsd, OpenTelemetry_Exporter", + "title": "Convert boolean values to metrics", + "description": "Whether or not to convert boolean values to metrics. True becomes 1, False becomes 0" , + "type": "boolean", + "f5expand": true, + "default": false + }, + "customTags": { + "$comment": "Required for certain consumers: DataDog", + "title": "Custom tags", + "description": "A collection of custom tags that are appended to the dynamically generated telemetry tags", + "type": "array", + "minItems": 1, + "items": { + "properties": { + "name": { + "description": "Name of this tag", + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "value": { + "description": "Value of this tag", + "type": "string", + "f5expand": true, + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "customHttpOpts": { + "title": "Custom Http Options", + "description": "Additional options to customize Http requests", + "allOf": [ { "$ref": "common_schema.json#/definitions/httpAgentOptions" } ] + }, + "otelExporter": { + "$comment": "Required for certain consumers: OpenTelemetry_Exporter", + "title": "Open Telemetry Exporter", + "description": "" , + "type": "string", + "enum": ["grpc", "json", "protobuf" ] + }, + "partitionerType": { + "$comment": "Required for certain consumers: Kafka", + "title": "Partitioner Type", + "description": "The type of partitioning strategy to use for the consumer" , + "type": "string" + }, + "partitionKey": { + "$comment": "Required for certain consumers: Kafka", + "title": "Partition Key", + "description": "The identifier to use for keyed partitions" , + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_Consumer" } } }, + "then": { + "required": [ + "class", + "type" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Consumer class", + "type": "string", + "enum": [ "Telemetry_Consumer" ] + }, + "enable": { + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "trace": { + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/trace" + } + ] + }, + "type": { + "title": "Type", + "description": "" , + "type": "string", + "enum": [ + "AWS_CloudWatch", + "AWS_S3", + "Azure_Log_Analytics", + "Azure_Application_Insights", + "DataDog", + "default", + "ElasticSearch", + "Generic_HTTP", + "Google_Cloud_Logging", + "Google_Cloud_Monitoring", + "Google_StackDriver", + "Graphite", + "Kafka", + "OpenTelemetry_Exporter", + "Splunk", + "Statsd", + "Sumo_Logic", + "F5_Cloud" + ] + }, + "enableHostConnectivityCheck": { + "$ref": "base_schema.json#/definitions/enableHostConnectivityCheck" + }, + "allowSelfSignedCert": { + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/allowSelfSignedCert" + } + ] + } + }, + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "addTags": {}, + "actions": {}, + "apiKey": {}, + "class": {}, + "customTags": {}, + "enable": {}, + "trace": {}, + "type": {}, + "enableHostConnectivityCheck": {}, + "allowSelfSignedCert": {}, + "host": {}, + "protocol": {}, + "port": {}, + "path": {}, + "method": {}, + "headers": {}, + "customOpts": {}, + "username": {}, + "passphrase": {}, + "format": {}, + "workspaceId": {}, + "useManagedIdentity": {}, + "instrumentationKey": {}, + "appInsightsResourceName": {}, + "maxBatchIntervalMs": {}, + "maxBatchSize": {}, + "region": {}, + "endpointUrl": {}, + "managementEndpointUrl": {}, + "odsOpinsightsEndpointUrl": {}, + "maxAwsLogBatchSize": {}, + "logGroup": {}, + "logStream": {}, + "metricNamespace": {}, + "metricPrefix": {}, + "bucket": {}, + "topic": {}, + "apiVersion": {}, + "index": {}, + "dataType": {}, + "authenticationProtocol": {}, + "projectId": {}, + "serviceEmail": {}, + "privateKey": {}, + "privateKeyId": {}, + "useServiceAccountToken": {}, + "clientCertificate": {}, + "rootCertificate": {}, + "outputMode": {}, + "fallbackHosts": {}, + "eventSchemaVersion": {}, + "f5csTenantId": {}, + "f5csSensorId": {}, + "payloadSchemaNid": {}, + "serviceAccount": {}, + "targetAudience": {}, + "useSSL": {}, + "proxy": {}, + "compressionType": {}, + "logScope": {}, + "logScopeId": {}, + "logId": {}, + "reportInstanceMetadata": {}, + "metricsPath": {}, + "service": {}, + "convertBooleansToMetrics": {}, + "exporter": {}, + "partitionerType": {}, + "partitionKey": {} + }, + "additionalProperties": false, + "dependencies": { + "actions": { + "allOf": [ + { + "properties": { "type": { "const": "Generic_HTTP" } } + } + ] + } + } + }, + { + "if": { "properties": { "type": { "const": "default" } } }, + "then": { + "required": [], + "properties": {} + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Generic_HTTP" } } }, + "then": { + "required": [ + "host" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "fallbackHosts": { + "type": "array", + "description": "List FQDNs or IP addresses to be used as fallback hosts" , + "minItems": 1, + "items": { + "allOf": [{ + "$ref": "#/definitions/host" + }] + } + }, + "protocol": { "$ref": "#/definitions/protocols", "default": "https" }, + "port": { "$ref": "#/definitions/port", "default": 443 }, + "path": { "$ref": "#/definitions/path", "default": "/" }, + "method": { "$ref": "#/definitions/method", "default": "POST" }, + "headers": { "$ref": "#/definitions/headers" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "proxy": { "$ref": "base_schema.json#/definitions/proxy" }, + "privateKey": { "$ref": "#/definitions/privateKey" }, + "clientCertificate": { "$ref": "#/definitions/clientCertificate" }, + "rootCertificate": { "$ref": "#/definitions/rootCertificate" }, + "outputMode": { "$ref": "#/definitions/outputMode", "default": "processed" }, + "actions": { "$ref": "#/definitions/genericHttpActions" }, + "compressionType": { "$ref": "#/definitions/compressionType", "default": "none" }, + "customOpts": { + "allOf": [ + { "$ref": "#/definitions/customOpts" }, + { "$ref": "#/definitions/customHttpOpts" } + ] + } + }, + "allOf": [ + { + "if": { "required": [ "clientCertificate" ] }, + "then": { "required": [ "privateKey" ] } + }, + { + "if": { "required": [ "privateKey" ] }, + "then": { "required": [ "clientCertificate" ] } + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Splunk" } } }, + "then": { + "required": [ + "host", + "passphrase" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "protocol": { "$ref": "#/definitions/protocols", "default": "https" }, + "port": { "$ref": "#/definitions/port", "default": 8088 }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "format": { "$ref": "#/definitions/format", "enum": [ "default", "legacy", "multiMetric" ], "default": "default" }, + "proxy": { "$ref": "base_schema.json#/definitions/proxy" }, + "compressionType": { "$ref": "#/definitions/compressionType", "default": "gzip" } + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Azure_Log_Analytics" } } }, + "then": { + "required": [ + "workspaceId" + ], + "properties": { + "workspaceId": { "$ref": "#/definitions/workspaceId" }, + "format": { "$ref": "#/definitions/format", "enum": [ "default", "propertyBased" ], "default": "default" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "useManagedIdentity": { "$ref": "#/definitions/useManagedIdentity", "default": false }, + "region": { "$ref": "#/definitions/region" }, + "managementEndpointUrl": { "$ref": "#/definitions/endpointUrl" }, + "odsOpinsightsEndpointUrl": { "$ref": "#/definitions/endpointUrl" } + }, + "allOf": [ + { + "dependencies": { + "passphrase": { + "anyOf": [ + { "not": {"required": [ "useManagedIdentity" ] } }, + { "properties": { "useManagedIdentity": { "const": false } } } + ] + } + } + }, + { + "if": { "not": { "required" : [ "useManagedIdentity"] } }, + "then": { "required": ["passphrase"] }, + "else": { + "if": { "properties": { "useManagedIdentity": { "const": true } } }, + "then": { "not": { "required": ["passphrase"] } }, + "else": { "required": ["passphrase"]} + } + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Azure_Application_Insights" } } }, + "then": { + "properties": { + "instrumentationKey": { "$ref": "#/definitions/instrumentationKey" }, + "maxBatchSize": { "$ref": "#/definitions/maxBatchSize", "default": 250 }, + "maxBatchIntervalMs": { "$ref": "#/definitions/maxBatchIntervalMs", "default": 5000 }, + "customOpts": { "$ref": "#/definitions/customOpts" }, + "useManagedIdentity": { "$ref": "#/definitions/useManagedIdentity", "default": false }, + "appInsightsResourceName": { "$ref": "#/definitions/appInsightsResourceName" }, + "region": { "$ref": "#/definitions/region" }, + "managementEndpointUrl": { "$ref": "#/definitions/endpointUrl" } + }, + "allOf": [ + { + "dependencies": { + "instrumentationKey": { + "allOf": [ + { + "anyOf": [ + { "not": { "required": [ "useManagedIdentity" ] } }, + { "properties": { "useManagedIdentity": { "const": false } } } + ] + }, + { + "not": { "required": ["appInsightsResourceName"] } + } + ] + } + } + }, + { + "if": { "not": { "required" : [ "useManagedIdentity"] } }, + "then": { "required": ["instrumentationKey"] }, + "else": { + "if": { "properties": { "useManagedIdentity": { "const": true } } }, + "then": { "not": { "required": ["instrumentationKey"] } }, + "else": { + "allOf": [ + { "required": [ "instrumentationKey" ]}, + { "not": { "required": [ "appInsightsResourceName" ] } } + ] + } + } + }, + { + "if": { "required": [ "appInsightsResourceName" ] }, + "then": { "properties": { "appInsightsResourceName": { "minLength": 1 } }} + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "AWS_CloudWatch" } } }, + "then": { + "required": [ + "region", + "dataType" + ], + "properties": { + "region": { "$ref": "#/definitions/region" }, + "dataType": { "$ref": "#/definitions/dataType", "default": "logs" }, + "username": { "$ref": "#/definitions/username" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "endpointUrl": { "$ref": "#/definitions/endpointUrl" } + }, + "allOf": [ + { "not": { "required": ["username"], "not": { "required": ["passphrase"] }}}, + { "not": { "required": ["passphrase"], "not": { "required": ["username"] }}}, + { + "if": { "properties": { "dataType": { "enum": ["logs", null] } } }, + "then": { + "properties": { + "maxAwsLogBatchSize": { "$ref": "#/definitions/maxAwsLogBatchSize", "default": 100 } + }, + "required": ["maxAwsLogBatchSize"] + } + }, + { "oneOf": + [ + { + "allOf": [ + { + "properties": { + "logGroup": { "$ref": "#/definitions/logGroup" }, + "logStream": { "$ref": "#/definitions/logStream" }, + "dataType": { + "allOf": + [ + { "$ref": "#/definitions/dataType"}, + { "enum": ["logs", null] } + ] + } + } + }, + { "required":[ "logGroup", "logStream" ] }, + { "not": { "required": ["metricNamespace"] }} + ] + }, + { + "allOf": [ + { + "properties": { + "metricNamespace": { "$ref": "#/definitions/metricNamespace" }, + "dataType": { + "allOf": [ + { "$ref": "#/definitions/dataType"}, + { "enum": ["metrics"] } + ] + } + } + }, + { "required":[ "metricNamespace" ] }, + { "not": { "required":[ "maxAwsLogBatchSize" ] }}, + { "not": { "required":[ "logStream" ] }}, + { "not": { "required":[ "logGroup" ] }} + ] + } + ] + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "AWS_S3" } } }, + "then": { + "required": [ + "region", + "bucket" + ], + "properties": { + "region": { "$ref": "#/definitions/region" }, + "bucket": { "$ref": "#/definitions/bucket" }, + "username": { "$ref": "#/definitions/username" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "endpointUrl": { "$ref": "#/definitions/endpointUrl" } + }, + "dependencies": { + "passphrase": [ "username" ], + "username":[ "passphrase" ] + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Graphite" } } }, + "then": { + "required": [ + "host" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "protocol": { "$ref": "#/definitions/protocols", "default": "https" }, + "port": { "$ref": "#/definitions/port", "default": 443 }, + "path": { "$ref": "#/definitions/path", "default": "/events/" } + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Kafka" } } }, + "then": { + "required": [ + "host", + "topic" + ], + "properties": { + "authenticationProtocol": { "$ref": "#/definitions/authenticationProtocol", "default": "None" }, + "customOpts": { "$ref": "#/definitions/customOpts" }, + "format": { "$ref": "#/definitions/format", "enum": [ "default", "split" ], "default": "default" }, + "host": { + "anyOf": [ + { + "$ref": "#/definitions/host" + }, + { + "type": "array", + "description": "List FQDNs or IP addresses to use as hosts" , + "minItems": 1, + "items": { + "allOf": [{ + "$ref": "#/definitions/host" + }] + } + } + ] + }, + "partitionerType": { "$ref": "#/definitions/partitionerType", "enum": ["default", "random", "cyclic", "keyed" ], "default": "default" }, + "protocol": { "$ref": "#/definitions/protocols", "default": "binaryTcpTls" }, + "port": { "$ref": "#/definitions/port", "default": 9092 }, + "topic": { "$ref": "#/definitions/topic" } + }, + "allOf": [ + { + "if": { "properties": { "authenticationProtocol": { "const": "SASL-PLAIN" } } }, + "then": { + "required": [ + "username" + ], + "properties": { + "username": { "$ref": "#/definitions/username" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" } + }, + "dependencies": { + "passphrase": [ "username" ] + } + }, + "else": {} + }, + { + "if": { "properties": { "authenticationProtocol": { "const": "TLS" } } }, + "then": { + "required": [ + "privateKey", + "clientCertificate" + ], + "allOf": [ + { "not": { "required": [ "username" ] } }, + { "not": { "required": [ "passphrase" ] } } + ], + "properties": { + "privateKey": { "$ref": "#/definitions/privateKey" }, + "clientCertificate": { "$ref": "#/definitions/clientCertificate" }, + "rootCertificate": { "$ref": "#/definitions/rootCertificate" }, + "protocol": { "const": "binaryTcpTls" } + } + }, + "else": {} + }, + { + "if": { "properties": { "partitionerType": { "const": "keyed" } } }, + "then": { + "required": [ "partitionKey" ], + "properties": { + "partitionKey": { "$ref": "#/definitions/partitionKey" } + } + }, + "else": {} + }, + { + "if": { "properties": { "partitionerType": { "not": { "const": "keyed" } } } }, + "then": { + "not": { "required": [ "partitionKey" ] } + }, + "else": {} + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "ElasticSearch" } } }, + "then": { + "required": [ + "host", + "index" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "protocol": { "$ref": "#/definitions/protocols", "default": "https" }, + "port": { "$ref": "#/definitions/port", "default": 9200 }, + "path": { "$ref": "#/definitions/path" }, + "username": { "$ref": "#/definitions/username" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, + "apiVersion": { "$ref": "#/definitions/apiVersion", "default": "6.0" }, + "index": { "$ref": "#/definitions/index" } + }, + "allOf": [ + { + "if": { "properties": { "apiVersion": { "pattern": "^[0-6][.]|^[0-6]$" } } }, + "then": { + "properties": { + "dataType": { + "$ref": "#/definitions/dataType", + "default": "f5.telemetry", + "minLength": 1 + } + } + }, + "else": { + "if": { "properties": { "apiVersion": { "pattern": "^7[.]|^7$" } } }, + "then": { + "properties": { + "dataType": { + "$ref": "#/definitions/dataType", + "default": "_doc", + "minLength": 1 + } + } + }, + "else": { + "allOf": [ + { "not": { "required": [ "dataType" ] } } + ] + } + } + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Sumo_Logic" } } }, + "then": { + "required": [ + "host", + "passphrase" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "protocol": { "$ref": "#/definitions/protocols", "default": "https" }, + "port": { "$ref": "#/definitions/port", "default": 443 }, + "path": { "$ref": "#/definitions/path", "default": "/receiver/v1/http/" }, + "passphrase": { "$ref": "base_schema.json#/definitions/secret" } + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Statsd" } } }, + "then": { + "required": [ + "host" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "protocol": { + "title": "Protocol", + "type": "string", + "enum": [ "tcp", "udp" ], + "default": "udp" + }, + "port": { "$ref": "#/definitions/port", "default": 8125 }, + "addTags": { "$ref": "#/definitions/autoTaggingStatsd" }, + "convertBooleansToMetrics": { "$ref": "#/definitions/convertBooleansToMetrics", "default": "false" } + } + }, + "else": {} + }, + { + "if": { + "properties": { "type": { "enum": ["Google_Cloud_Monitoring", "Google_StackDriver", "Google_Cloud_Logging"] } } + }, + "then": { + "required": [ + "serviceEmail" + ], + "properties": { + "privateKeyId": { "$ref": "#/definitions/privateKeyId" }, + "serviceEmail": { "$ref": "#/definitions/serviceEmail" }, + "privateKey": { "$ref": "base_schema.json#/definitions/secret" }, + "useServiceAccountToken": { "$ref": "#/definitions/useServiceAccountToken", "default": false }, + "reportInstanceMetadata": { "$ref": "#/definitions/reportInstanceMetadata", "default": false } + }, + "allOf": [ + { + "dependencies": { + "privateKeyId": { + "anyOf": [ + { "not": {"required": [ "useServiceAccountToken" ] } }, + { "properties": { "useServiceAccountToken": { "const": false } } } + ] + } + } + }, + { + "dependencies": { + "privateKey": { + "anyOf": [ + { "not": {"required": [ "useServiceAccountToken" ] } }, + { "properties": { "useServiceAccountToken": { "const": false } } } + ] + } + } + }, + { + "if": { + "anyOf": [ + { "not": { "required" : [ "useServiceAccountToken"] } }, + { "properties": { "useServiceAccountToken": { "const": false } } } + ] + }, + "then": { "required": ["privateKeyId", "privateKey"] }, + "else": { "not": { "required": ["privateKeyId", "privateKey"] } } + }, + { + "if": { "properties": { "type": { "enum": ["Google_Cloud_Monitoring", "Google_StackDriver"] } } }, + "then": { + "properties": { + "projectId": { "$ref": "#/definitions/projectId"} + }, + "required": ["projectId"] + } + }, + { + "if": { "properties": { "type": { "const": "Google_Cloud_Logging" } } }, + "then": { + "properties": { + "logScope": { "$ref": "#/definitions/logScope", "default": "projects" }, + "logScopeId": { "$ref": "#/definitions/logScopeId"}, + "logId": { "$ref": "#/definitions/logId"} + }, + "required": ["logScope", "logScopeId", "logId"] + } + } + ] + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "F5_Cloud" } } }, + "then": { + "required": [ + "f5csTenantId", + "f5csSensorId", + "payloadSchemaNid", + "serviceAccount", + "targetAudience" + ], + "properties": { + "port": { "$ref": "#/definitions/port", "default": 443 }, + "eventSchemaVersion": { "$ref": "#/definitions/eventSchemaVersion" }, + "f5csTenantId": { "$ref": "#/definitions/f5csTenantId" }, + "f5csSensorId": { "$ref": "#/definitions/f5csSensorId" }, + "payloadSchemaNid": { "$ref": "#/definitions/payloadSchemaNid" }, + "serviceAccount": { "$ref": "#/definitions/serviceAccount" }, + "targetAudience": { "$ref": "#/definitions/targetAudience" }, + "useSSL": { "$ref": "#/definitions/useSSL", "default": true } + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "DataDog" } } }, + "then": { + "required": [ + "apiKey" + ], + "properties": { + "apiKey": { "$ref": "#/definitions/apiKey" }, + "compressionType": { "$ref": "#/definitions/compressionType", "default": "none" }, + "region": { "$ref": "#/definitions/region", "enum": ["US1", "US3", "EU1", "US1-FED"], "default": "US1" }, + "service": { "$ref": "#/definitions/service", "default": "f5-telemetry" }, + "metricPrefix": { "$ref": "#/definitions/metricPrefix" }, + "convertBooleansToMetrics": { "$ref": "#/definitions/convertBooleansToMetrics", "default": "false" }, + "customTags": { "$ref": "#/definitions/customTags" }, + "customOpts": { + "allOf": [ + { "$ref": "#/definitions/customOpts" }, + { "$ref": "#/definitions/customHttpOpts" } + ] + }, + "proxy": { "$ref": "base_schema.json#/definitions/proxy" } + } + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "OpenTelemetry_Exporter" } } }, + "then": { + "required": [ + "host", + "port" + ], + "properties": { + "host": { "$ref": "#/definitions/host" }, + "port": { "$ref": "#/definitions/port" }, + "headers": { "$ref": "#/definitions/headers" }, + "metricsPath": { "$ref": "#/definitions/path" }, + "convertBooleansToMetrics": { "$ref": "#/definitions/convertBooleansToMetrics", "default": "false" }, + "exporter": { "$ref": "#/definitions/otelExporter", "default": "protobuf" }, + "privateKey": { "$ref": "#/definitions/privateKey" }, + "clientCertificate": { "$ref": "#/definitions/clientCertificate" }, + "rootCertificate": { "$ref": "#/definitions/rootCertificate" } + }, + "dependencies": { + "clientCertificate": ["privateKey"], + "privateKey": ["clientCertificate"] + }, + "allOf": [ + { + "if": { "properties": { "exporter": { "const": "grpc" } } } , + "then": { + "properties": { + "useSSL": { "$ref": "#/definitions/useSSL", "default": true } + }, + "allOf": [ + { + "if": { "properties": { "useSSL": { "const": false } } }, + "then": { + "allOf": [ + { "not": { "required": ["privateKey"] } }, + { "not": { "required": ["clientCertificate"] } }, + { "not": { "required": ["rootCertificate"] } } + ] + } + }, + { + "allOf": [ + { "not": { "required": ["metricsPath"] } }, + { "not": { "required": ["protocol"] } } + ] + } + ] + }, + "else": { + "properties": { + "protocol": { "$ref": "#/definitions/protocols", "default": "http", "enum": ["http", "https"] } + }, + "allOf": [ + { "not": { "required": ["useSSL"] } }, + { + "if": { "properties": { "protocol": { "const": "http" } } }, + "then": { + "allOf": [ + { "not": { "required": ["privateKey"] } }, + { "not": { "required": ["clientCertificate"] } }, + { "not": { "required": ["rootCertificate"] } } + ] + } + } + ] + } + } + ] + }, + "else": {} + } + ] + }, + "else": {} + } + ] +} diff --git a/src/schema/1.36.0/controls_schema.json b/src/schema/1.36.0/controls_schema.json new file mode 100644 index 00000000..0ecab244 --- /dev/null +++ b/src/schema/1.36.0/controls_schema.json @@ -0,0 +1,174 @@ +{ + "$id": "controls_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Controls schema", + "description": "", + "type": "object", + "allOf": [ + { + "if": { "properties": { "class": { "const": "Controls" } } }, + "then": { + "required": [ + "class" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Controls class", + "type": "string", + "enum": [ "Controls" ] + }, + "logLevel": { + "title": "Logging Level", + "description": "", + "type": "string", + "default": "info", + "enum": [ + "verbose", + "debug", + "info", + "error" + ] + }, + "debug": { + "title": "Enable debug mode", + "description": "", + "type": "boolean", + "default": false + }, + "memoryThresholdPercent": { + "title": "Memory Usage Threshold (Percentage of Available Process Memory)", + "description": "Once memory usage reaches this value, processing may temporarily cease until levels return below threshold. Defaults to 90%", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 90 + }, + "listenerMode": { + "title": "Event Listener events parsing mode", + "description": "Event Listener events parsing mode. \"buffer\" is more performant but under the high memory usage events may result in OOM. \"string\" is less performant but more chance to have lower RSS", + "type": "string", + "enum": [ + "buffer", + "string" + ] + }, + "listenerStrategy": { + "title": "Event Listener events buffering strategy due high memory usage events", + "description": "Event Listener events buffering strategy. \"drop\" drops all new chunks of data, but keeps pending data to process - less memory usage but loosing data. \"ring\" keeps buffering data by overriding peding data - higher memory usage but less chance to get data lost.", + "type": "string", + "enum": [ + "drop", + "ring" + ] + }, + "memoryMonitor": { + "title": "Memory Monitor configuration options", + "description": "Memory Monitor configuration options allow configuring thresholds for various parameters to help Telemetry Streaming avoid extreme conditions like Out-Of-Memory.", + "type": "object", + "properties": { + "interval": { + "title": "", + "description": "", + "enum": [ + "default", + "aggressive" + ], + "default": "default" + }, + "logFrequency": { + "title": "Logging Frequency (in sec.)", + "description": "Number of seconds to use to log information about memory usage. Defaults to 10 sec.", + "type": "integer", + "minimum": 1, + "default": 10 + }, + "logLevel": { + "title": "Logging Level", + "description": "Logging Level to use to log information about memory usage. Defaults to \"debug\"", + "default": "debug", + "allOf": [ + { "$ref": "#/allOf/0/then/properties/logLevel" } + ] + }, + "memoryThresholdPercent": { + "title": "Memory Usage Threshold (Percentage of Available Process Memory)", + "description": "Once memory usage reaches this value, processing may temporarily cease until levels return below threshold * \"thresholdReleasePercent\". Defaults to 90%. NOTE: the property is the same as the one from parent object but it take precedens over the parent's one if specified.", + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "osFreeMemory": { + "title": "OS Free memory (in MB)", + "description": "Amount of OS Free memory (in MB) below that processing may temporarily ceasae until levels return above theshold. Defaults to 30 MB.", + "type": "integer", + "minimum": 1, + "default": 30 + }, + "provisionedMemory": { + "title": "Provisioned Memory for Application (in MB.)", + "description": "Amount of Memory in MB. that application should not exceed. Once limit exceed, processing may temporarily cease until levels return below threshold. Defaults to the 'runtime.maxHeapSize' value. Maximum should not exceed 'runtime.maxHeapSize'.", + "type": "integer", + "minimum": 1, + "heapSizeLimitCheck": true + }, + "thresholdReleasePercent": { + "title": "Memory Usage Threshold Release (Percentage of Available Threshold Memory)", + "description": "Once memory usage reaches value described in \"memoryThresholdPercent\", processing may temporarily cease until levels return below threshold * \"thresholdReleasePercent\". Defaults to 90%.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 90 + } + }, + "additionalProperties": false, + "anyOf": [ + { "required": ["interval"] }, + { "required": ["logFrequency"] }, + { "required": ["logLevel"] }, + { "required": ["memoryThresholdPercent"] }, + { "required": ["osFreeMemory"] }, + { "required": ["provisionedMemory"] }, + { "required": ["thresholdReleasePercent"] } + ] + }, + "runtime": { + "title": "Runtime Configuration Options. EXPERIMENTAL!", + "description": "Runtime Configuration Options (V8). Allows to tune the V8 configuration. EXPERIMENTAL!", + "type": "object", + "properties": { + "enableGC": { + "title": "Enables the V8 garbage collector. EXPERIMENTAL!", + "description": "Grants Telemetry Streaming access to the V8 garbage collector, which helps Telemetry Streaming cleanup memory when usage exceeds thresholds. EXPERIMENTAL!", + "type": "boolean", + "default": false + }, + "maxHeapSize": { + "title": "Increases the V8 maximum heap size. EXPERIMENTAL!", + "description": "Increases V8 maximum heap size to enable more memory usage and prevent Heap-Out-Of-Memory error. EXPERIMENTAL!", + "type": "number", + "minimum": 1400, + "default": 1400 + }, + "httpTimeout": { + "title": "Increases the timeout value in seconds for incoming REST API HTTP requests. EXPERIMENTAL!", + "description": "Increases the timeout value in seconds for incoming REST API HTTP requests that allows Telemetry Streaming to avoid TimeoutException error for long lasting operations. Defaults to 60 seconds. EXPERIMENTAL!", + "type": "number", + "default": 60, + "minimum": 60, + "maximum": 600 + } + }, + "additionalProperties": false, + "anyOf": [ + { "required": ["enableGC"] }, + { "required": ["maxHeapSize"] } + ] + } + }, + "additionalProperties": false + }, + "else": {} + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/endpoints_schema.json b/src/schema/1.36.0/endpoints_schema.json new file mode 100644 index 00000000..1e12b4b3 --- /dev/null +++ b/src/schema/1.36.0/endpoints_schema.json @@ -0,0 +1,190 @@ +{ + "$id": "endpoints_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Endpoints schema", + "description": "", + "type": "object", + "definitions": { + "endpoint": { + "title": "Telemetry Endpoint", + "description": "", + "type": "object", + "properties": { + "enable": { + "title": "Enable endpoint", + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "name": { + "title": "Endpoint name", + "type": "string", + "minLength": 1 + }, + "numericalEnums": { + "title": "SNMP Options: print enums numerically", + "type": "boolean" + }, + "path": { + "title": "Path to query data from", + "type": "string", + "minLength": 1 + }, + "protocol": { + "title": "Endpoint protocol used to fetch data", + "type": "string", + "enum": ["http", "snmp"], + "default": "http" + } + }, + "allOf": [ + { + "if": { "properties": { "protocol": { "const": "snmp" } } }, + "then": { + "properties": { + "numericalEnums": { + "default": false + }, + "path": { + "pattern": "^[a-zA-Z0-9.]+$" + } + } + }, + "else": { + "not": { + "required": ["numericalEnums"] + } + } + } + ], + "additionalProperties": false + }, + "endpoints": { + "title": "Telemetry Endpoints", + "description": "", + "type": "object", + "properties": { + "enable": { + "title": "Enable endpoints", + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "basePath": { + "title": "Base Path", + "description": "Optional base path value to prepend to each individual endpoint paths", + "type": "string", + "default": "" + }, + "items": { + "title": "Items", + "description": "Object with each property an endpoint with their own properties", + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/definitions/endpoint" + }, + { + "required": [ "path"] + } + ] + }, + "minProperties": 1 + } + } + }, + "endpointsObjectRef": { + "allOf": [ + { + "$ref": "#/definitions/endpoints" + }, + { + "properties": { + "enable": {}, + "basePath": {}, + "items": {} + }, + "required": [ "items" ], + "additionalProperties": false + } + ] + }, + "endpointObjectRef": { + "allOf": [ + { + "$ref": "#/definitions/endpoint" + }, + { + "properties": { + "enable": {}, + "name": {}, + "numericalEnums": {}, + "path": {}, + "protocol": {} + }, + "required": [ "name", "path" ], + "additionalProperties": false + } + ] + }, + "endpointsPointerRef": { + "title": "Telemetry_Endpoints Name", + "description": "Name of the Telemetry_Endpoints object", + "type": "string", + "declarationClass": "Telemetry_Endpoints", + "minLength": 1 + }, + "endpointsItemPointerRef": { + "title": "Telemetry_Endpoints Name and Item Key", + "description": "Name of the Telemetry_Endpoints object and the endpoint item key, e.g endpointsA/item1", + "type": "string", + "declarationClassProp": { + "path" :"Telemetry_Endpoints/items", + "partsNum": 2 + }, + "minLength": 1 + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_Endpoints" } } }, + "then": { + "required": [ + "class", + "items" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Endpoints class", + "type": "string", + "enum": [ "Telemetry_Endpoints" ] + } + }, + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "class": {}, + "enable": {}, + "basePath": {}, + "items": {} + }, + "additionalProperties": false + }, + { + "$ref": "#/definitions/endpoints" + } + ] + }, + "else": {} + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/ihealth_poller_schema.json b/src/schema/1.36.0/ihealth_poller_schema.json new file mode 100644 index 00000000..0e6969a4 --- /dev/null +++ b/src/schema/1.36.0/ihealth_poller_schema.json @@ -0,0 +1,238 @@ +{ + "$id": "ihealth_poller_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming iHealth Poller schema", + "description": "", + "type": "object", + "definitions": { + "time24hr": { + "title": "Time in HH:MM, 24hr", + "description": "", + "type": "string", + "pattern": "^([0-9]|0[0-9]|1[0-9]|2[0-3]):([0-9]|[0-5][0-9])?$" + }, + "iHealthPoller": { + "$comment": "system_schema.json should be updated when new property added", + "title": "iHealth Poller", + "description": "", + "type": "object", + "required": [ + "interval", + "username", + "passphrase" + ], + "properties": { + "enable": { + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "trace": { + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/trace" + } + ] + }, + "proxy": { + "title": "Proxy configuration", + "properties": { + "port": { + "default": 80 + }, + "protocol": { + "default": "http" + } + }, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/proxy" + } + ] + }, + "username": { + "title": "iHealth Username", + "$ref": "base_schema.json#/definitions/username" + }, + "passphrase": { + "title": "iHealth Passphrase", + "$ref": "base_schema.json#/definitions/secret" + }, + "downloadFolder": { + "title": "Directory to download/generate Qkview file", + "description": "", + "type": "string", + "minLength": 1, + "pathExists": true + }, + "interval": { + "title": "Operating interval", + "description": "" , + "type": "object", + "properties": { + "timeWindow": { + "title": "Two or more hours window in 24hr format that iHealth data can be sent", + "description": "", + "type": "object", + "properties": { + "start": { + "title": "Time when the window starts", + "$ref": "#/definitions/time24hr" + }, + "end": { + "title": "Time when the window ends", + "$ref": "#/definitions/time24hr" + } + }, + "timeWindowMinSize": 120, + "required": [ "start", "end" ], + "additionalProperties": false + }, + "frequency": { + "title": "Interval frequency", + "description": "", + "type": "string", + "default": "daily", + "enum": [ + "daily", + "weekly", + "monthly" + ] + } + + }, + "required": [ + "timeWindow" + ], + "allOf": [ + { + "if": { "properties": { "frequency": { "const": "daily" } } }, + "then": { + "properties": { + "timeWindow": {}, + "frequency": {} + }, + "additionalProperties": false + } + }, + { + "if": { "properties": { "frequency": { "const": "weekly" } } }, + "then": { + "properties": { + "timeWindow": {}, + "frequency": {}, + "day": { + "title": "", + "description": "", + "oneOf": [ + { + "type": "string", + "pattern": "^([mM]onday|[tT]uesday|[wW]ednesday|[tT]hursday|[fF]riday|[sS]aturday|[sS]unday)$" + }, + { + "$comment": "0 and 7 eq. Sunday", + "type": "integer", + "minimum": 0, + "maximum": 7 + } + ] + } + }, + "required": [ "day" ], + "additionalProperties": false + } + }, + { + "if": { "properties": { "frequency": { "const": "monthly" } } }, + "then": { + "properties": { + "timeWindow": {}, + "frequency": {}, + "day": { + "title": "", + "description": "", + "type": "integer", + "minimum": 1, + "maximum": 31 + } + }, + "required": [ "day" ], + "additionalProperties": false + } + } + ] + } + } + }, + "iHealthPollerPointerRef": { + "type": "string", + "minLength": 1, + "declarationClass": "Telemetry_iHealth_Poller" + }, + "iHealthPollerObjectRef": { + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "enable": {}, + "trace": {}, + "interval": {}, + "proxy": {}, + "username": {}, + "passphrase": {}, + "downloadFolder": {} + }, + "additionalProperties": false + }, + { + "$ref": "ihealth_poller_schema.json#/definitions/iHealthPoller" + } + ] + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_iHealth_Poller" } } }, + "then": { + "required": [ + "class", + "username", + "passphrase" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming iHealth Poller class", + "type": "string", + "enum": [ "Telemetry_iHealth_Poller" ] + } + }, + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "class": {}, + "enable": {}, + "trace": {}, + "interval": {}, + "proxy": {}, + "username": {}, + "passphrase": {}, + "downloadFolder": {} + }, + "additionalProperties": false + }, + { + "$ref": "#/definitions/iHealthPoller" + } + ] + }, + "else": {}, + "$comment": "Telemetry_iHealth_Poller should be either built-in within Telemetry_System or referenced by Telemetry_System(s), otherwise it will be treated as disabled" + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/listener_schema.json b/src/schema/1.36.0/listener_schema.json new file mode 100644 index 00000000..d3b9434e --- /dev/null +++ b/src/schema/1.36.0/listener_schema.json @@ -0,0 +1,85 @@ +{ + "$id": "listener_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming event listener schema", + "description": "", + "type": "object", + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_Listener" } } }, + "then": { + "required": [ + "class" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Event Listener class", + "type": "string", + "enum": [ "Telemetry_Listener" ] + }, + "enable": { + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "trace": { + "default": false, + "oneOf": [ + { + "$ref": "base_schema.json#/definitions/trace" + }, + { + "$ref": "base_schema.json#/definitions/traceV2" + } + ] + }, + "port": { + "minimum": 1024, + "maximum": 65535, + "default": 6514, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/port" + } + ] + }, + "tag": { + "$comment": "Deprecated! Use actions with a setTag action.", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/tag" + } + ] + }, + "match": { + "default": "", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/match" + } + ] + }, + "actions": { + "title": "Actions", + "description": "Actions to be performed on the listener.", + "default": [ + { + "setTag": { + "tenant": "`T`", + "application": "`A`" + } + } + ], + "allOf": [{ "$ref": "actions_schema.json#/definitions/inputDataStreamActionsChain" }] + } + }, + "additionalProperties": false + }, + "else": {} + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/namespace_schema.json b/src/schema/1.36.0/namespace_schema.json new file mode 100644 index 00000000..f6cb09fc --- /dev/null +++ b/src/schema/1.36.0/namespace_schema.json @@ -0,0 +1,92 @@ +{ + "$id": "namespace_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Namespace schema", + "description": "", + "type": "object", + "definitions": { + "namespace": { + "required": [ + "class" + ], + "type": "object", + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Namespace class", + "type": "string", + "enum": [ "Telemetry_Namespace" ] + } + }, + "additionalProperties": { + "$comment": "All objects supported under a Telemetry Namespace", + "properties": { + "class": { + "title": "Class", + "type": "string", + "enum": [ + "Telemetry_System", + "Telemetry_System_Poller", + "Telemetry_Listener", + "Telemetry_Consumer", + "Telemetry_Pull_Consumer", + "Telemetry_iHealth_Poller", + "Telemetry_Endpoints", + "Shared" + ] + } + }, + "allOf": [ + { + "$ref": "system_schema.json#" + }, + { + "$ref": "system_poller_schema.json#" + }, + { + "$ref": "listener_schema.json#" + }, + { + "$ref": "consumer_schema.json#" + }, + { + "$ref": "pull_consumer_schema.json#" + }, + { + "$ref": "ihealth_poller_schema.json#" + }, + { + "$ref": "endpoints_schema.json#" + }, + { + "$ref": "shared_schema.json#" + } + ] + } + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_Namespace" } } }, + "then": { + "required": [ + "class" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Namespace class", + "type": "string", + "enum": [ "Telemetry_Namespace" ] + } + }, + "allOf": [ + { + "$ref": "#/definitions/namespace" + } + ] + }, + "else": {} + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/pull_consumer_schema.json b/src/schema/1.36.0/pull_consumer_schema.json new file mode 100644 index 00000000..0747cbfd --- /dev/null +++ b/src/schema/1.36.0/pull_consumer_schema.json @@ -0,0 +1,101 @@ +{ + "$id": "pull_consumer_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Pull Consumer schema", + "description": "", + "type": "object", + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_Pull_Consumer" } } }, + "then": { + "required": [ + "class", + "type", + "systemPoller" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming Pull Consumer class", + "type": "string", + "enum": [ "Telemetry_Pull_Consumer" ] + }, + "enable": { + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "trace": { + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/trace" + } + ] + }, + "type": { + "title": "Type", + "description": "" , + "type": "string", + "enum": [ + "default", + "Prometheus" + ] + }, + "systemPoller": { + "title": "Pointer to System Poller(s)", + "anyOf": [ + { + "$ref": "system_poller_schema.json#/definitions/systemPollerPointerRef" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "system_poller_schema.json#/definitions/systemPollerPointerRef" + } + ] + }, + "minItems": 1 + } + ] + } + }, + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "class": {}, + "enable": {}, + "trace": {}, + "type": {}, + "systemPoller": {} + }, + "additionalProperties": false + }, + { + "if": { "properties": { "type": { "const": "default" } } }, + "then": { + "required": [], + "properties": {} + }, + "else": {} + }, + { + "if": { "properties": { "type": { "const": "Prometheus" } } }, + "then": { + "required": [], + "properties": {} + }, + "else": {} + } + ] + }, + "else": {} + } + ] +} diff --git a/src/schema/1.36.0/shared_schema.json b/src/schema/1.36.0/shared_schema.json new file mode 100644 index 00000000..aa96cb2e --- /dev/null +++ b/src/schema/1.36.0/shared_schema.json @@ -0,0 +1,50 @@ +{ + "$id": "shared_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry streaming Shared schema", + "description": "", + "type": "object", + "allOf": [ + { + "if": { "properties": { "class": { "const": "Shared" } } }, + "then": { + "required": [ + "class" + ], + "properties": { + "class": { + "title": "Class", + "description": "Telemetry streaming Shared class", + "type": "string", + "enum": [ "Shared" ] + } + }, + "additionalProperties": { + "properties": { + "class": { + "title": "Class", + "type": "string", + "enum": [ + "Constants", + "Secret" + ] + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Constants" } } }, + "then": { "$ref": "base_schema.json#/definitions/constants" }, + "else": {} + }, + { + "if": { "properties": { "class": { "const": "Secret" } } }, + "then": { "$ref": "base_schema.json#/definitions/secret" }, + "else": {} + } + ] + } + }, + "else": {} + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/system_poller_schema.json b/src/schema/1.36.0/system_poller_schema.json new file mode 100644 index 00000000..5de36d6a --- /dev/null +++ b/src/schema/1.36.0/system_poller_schema.json @@ -0,0 +1,274 @@ +{ + "$id": "system_poller_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming system poller schema", + "description": "", + "type": "object", + "definitions": { + "systemPoller": { + "$comment": "system_schema.json should be updated when new property added", + "title": "System Poller", + "description": "", + "type": "object", + "properties": { + "enable": { + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "interval": { + "title": "Collection interval (in seconds)", + "description": "If endpointList is specified, minimum=1. Without endpointList, minimum=60 and maximum=60000. Allows setting interval=0 to not poll on an interval.", + "type": "integer", + "default": 300 + }, + "trace": { + "$ref": "base_schema.json#/definitions/trace" + }, + "tag": { + "$comment": "Deprecated! Use actions with a setTag action.", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/tag" + } + ] + }, + "actions": { + "title": "Actions", + "description": "Actions to be performed on the systemPoller.", + "default": [ + { + "setTag": { + "tenant": "`T`", + "application": "`A`" + } + } + ], + "allOf": [{ "$ref": "actions_schema.json#/definitions/inputDataStreamActionsChain" }] + }, + "endpointList": { + "title": "Endpoint List", + "description": "List of endpoints to use in data collection", + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "endpoints_schema.json#/definitions/endpointsPointerRef" + }, + { + "$ref": "endpoints_schema.json#/definitions/endpointsItemPointerRef" + }, + { + "if": { "required": [ "items" ]}, + "then": { + "$ref": "endpoints_schema.json#/definitions/endpointsObjectRef" + }, + "else": { + "$ref": "endpoints_schema.json#/definitions/endpointObjectRef" + } + } + + ] + }, + "minItems": 1 + }, + { + "$ref": "endpoints_schema.json#/definitions/endpointsPointerRef" + }, + { + "$ref": "endpoints_schema.json#/definitions/endpointsObjectRef" + } + ] + }, + "workers": { + "title": "Worker Count", + "description": "Number of workers to create, which affects processing of simultaneous requests to device.", + "type": "integer", + "default": 5, + "minimum": 1 + }, + "chunkSize": { + "title": "Chunk Size", + "description": "The maximum number of items to fetch at a time for a given endpoint. Requests with reduced response size can help improve CPU/memory utilization.", + "type": "integer", + "default": 30, + "minimum": 1 + }, + "httpAgentOpts": { + "title": "Http Agent Opts", + "description": "Additional http agent options to use", + "type": "array", + "allOf": [ + { "$ref": "common_schema.json#/definitions/additionalOptions" }, + { "$ref": "common_schema.json#/definitions/httpAgentOptions" } + ] + } + }, + "oneOf": [ + { + "allOf": [ + { + "if": { "required": [ "endpointList" ] }, + "then": { + "properties": { + "interval": { + "minimum": 1 + } + } + }, + "else": { + "properties":{ + "interval": { + "minimum": 60, + "maximum": 6000 + } + } + } + } + ] + }, + { + "allOf": [ + { + "properties": { + "interval": { + "enum": [0] + } + } + } + ] + } + ] + }, + "systemPollerPointerRef": { + "type": "string", + "minLength": 1, + "declarationClass": "Telemetry_System_Poller" + }, + "systemPollerObjectRef": { + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "enable": {}, + "trace": {}, + "interval": {}, + "tag": {}, + "actions": {}, + "endpointList": {}, + "workers": {}, + "chunkSize": {}, + "httpAgentOpts": {} + }, + "additionalProperties": false + }, + { + "$ref": "#/definitions/systemPoller" + } + ] + } + }, + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_System_Poller" } } }, + "then": { + "required": [ + "class" + ], + "dependencies": { + "passphrase": [ "username" ] + }, + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming System Poller class", + "type": "string", + "enum": [ "Telemetry_System_Poller" ] + }, + "host": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "default": "localhost", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/host" + } + ] + }, + "port": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "default": 8100, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/port" + } + ] + }, + "protocol": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "default": "http", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/protocol" + } + ] + }, + "allowSelfSignedCert": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "title": "Allow Self-Signed Certificate", + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/allowSelfSignedCert" + } + ] + }, + "enableHostConnectivityCheck": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "$ref": "base_schema.json#/definitions/enableHostConnectivityCheck" + }, + "username": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "$ref": "base_schema.json#/definitions/username" + }, + "passphrase": { + "$comment": "Deprecated! Use Telemetry_System to define target device", + "$ref": "base_schema.json#/definitions/secret" + } + }, + "allOf": [ + { + "$comment": "This allows enforcement of no additional properties in this nested schema - could reuse above properties but prefer a separate block", + "properties": { + "class": {}, + "enable": {}, + "trace": {}, + "interval": {}, + "tag": {}, + "host": {}, + "port": {}, + "protocol": {}, + "allowSelfSignedCert": {}, + "enableHostConnectivityCheck": {}, + "username": {}, + "passphrase": {}, + "actions": {}, + "endpointList": {}, + "workers": {}, + "chunkSize": {}, + "httpAgentOpts": {} + }, + "additionalProperties": false + }, + { + "$ref": "#/definitions/systemPoller" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/schema/1.36.0/system_schema.json b/src/schema/1.36.0/system_schema.json new file mode 100644 index 00000000..28811496 --- /dev/null +++ b/src/schema/1.36.0/system_schema.json @@ -0,0 +1,124 @@ +{ + "$id": "system_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming System schema", + "description": "", + "type": "object", + "allOf": [ + { + "if": { "properties": { "class": { "const": "Telemetry_System" } } }, + "then": { + "required": [ + "class" + ], + "dependencies": { + "passphrase": [ "username" ] + }, + "properties": { + "class": { + "title": "Class", + "description": "Telemetry Streaming System class", + "type": "string", + "enum": [ "Telemetry_System" ] + }, + "enable": { + "title": "Enable all pollers attached to device", + "default": true, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/enable" + } + ] + }, + "trace": { + "$ref": "base_schema.json#/definitions/trace" + }, + "host": { + "title": "System connection address", + "default": "localhost", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/host" + } + ] + }, + "port": { + "title": "System connection port", + "default": 8100, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/port" + } + ] + }, + "protocol": { + "title": "System connection protocol", + "default": "http", + "allOf": [ + { + "$ref": "base_schema.json#/definitions/protocol" + } + ] + }, + "allowSelfSignedCert": { + "title": "Allow Self-Signed Certificate", + "default": false, + "allOf": [ + { + "$ref": "base_schema.json#/definitions/allowSelfSignedCert" + } + ] + }, + "enableHostConnectivityCheck": { + "$ref": "base_schema.json#/definitions/enableHostConnectivityCheck" + }, + "username": { + "title": "System Username", + "$ref": "base_schema.json#/definitions/username" + }, + "passphrase": { + "title": "System Passphrase", + "$ref": "base_schema.json#/definitions/secret" + }, + "systemPoller": { + "title": "System Poller declaration", + "oneOf": [ + { + "$ref": "system_poller_schema.json#/definitions/systemPollerPointerRef" + }, + { + "$ref": "system_poller_schema.json#/definitions/systemPollerObjectRef" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "system_poller_schema.json#/definitions/systemPollerObjectRef" + }, + { + "$ref": "system_poller_schema.json#/definitions/systemPollerPointerRef" + } + ] + }, + "minItems": 1 + } + ] + }, + "iHealthPoller": { + "title": "iHealth Poller declaration", + "oneOf": [ + { + "$ref": "ihealth_poller_schema.json#/definitions/iHealthPollerPointerRef" + }, + { + "$ref": "ihealth_poller_schema.json#/definitions/iHealthPollerObjectRef" + } + ] + } + }, + "additionalProperties": false + } + } + ] +} \ No newline at end of file diff --git a/src/schema/latest/base_schema.json b/src/schema/latest/base_schema.json index 6acb1db3..5f319c2c 100644 --- a/src/schema/latest/base_schema.json +++ b/src/schema/latest/base_schema.json @@ -242,8 +242,8 @@ "description": "Version of ADC Declaration schema this declaration uses", "type": "string", "$comment": "IMPORTANT: In enum array, please put current schema version first, oldest-supported version last. Keep enum array sorted most-recent-first.", - "enum": [ "1.35.0", "1.34.0", "1.33.0", "1.32.0", "1.31.0", "1.30.0", "1.29.0", "1.28.0", "1.27.1", "1.27.0", "1.26.0", "1.25.0", "1.24.0", "1.23.0", "1.22.0", "1.21.0", "1.20.1", "1.20.0", "1.19.0", "1.18.0", "1.17.0", "1.16.0", "1.15.0", "1.14.0", "1.13.0", "1.12.0", "1.11.0", "1.10.0", "1.9.0", "1.8.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.3.0", "1.2.0", "1.1.0", "1.0.0", "0.9.0" ], - "default": "1.35.0" + "enum": [ "1.36.0", "1.35.0", "1.34.0", "1.33.0", "1.32.0", "1.31.0", "1.30.0", "1.29.0", "1.28.0", "1.27.1", "1.27.0", "1.26.0", "1.25.0", "1.24.0", "1.23.0", "1.22.0", "1.21.0", "1.20.1", "1.20.0", "1.19.0", "1.18.0", "1.17.0", "1.16.0", "1.15.0", "1.14.0", "1.13.0", "1.12.0", "1.11.0", "1.10.0", "1.9.0", "1.8.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.3.0", "1.2.0", "1.1.0", "1.0.0", "0.9.0" ], + "default": "1.36.0" }, "$schema": { "title": "Schema", diff --git a/src/schema/latest/common_schema.json b/src/schema/latest/common_schema.json new file mode 100644 index 00000000..dfb546bf --- /dev/null +++ b/src/schema/latest/common_schema.json @@ -0,0 +1,92 @@ +{ + "$id": "common_schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telemetry Streaming Common Objects Schema", + "description": "", + "type": "object", + "definitions": { + "additionalOptions": { + "$comment": "Additional custom options (e.g. feature flags for testing)", + "title": "Additional custom options (Target Object/Class Dependent)", + "description": "Additional custom options for use by target class. Refer to corresponding class schema for acceptable keys and values.", + "type": "array", + "items": { + "properties": { + "name": { + "description": "Name of the option", + "type": "string", + "f5expand": true, + "minLength": 1 + }, + "value": { + "description": "Value of the option", + "minLength": 1, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "allOf": [ + { + "f5expand": true + }, + { + "$ref": "base_schema.json#/definitions/stringOrSecret" + } + ] + } + ] + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "httpAgentOptions": { + "title": "Http Agent Options", + "description": "Set of additional options to customize http agent.", + "type": "array", + "items": { + "properties": { + "name": { + "enum": [ + "keepAlive", + "keepAliveMsecs", + "maxSockets", + "maxFreeSockets" + ] + } + }, + "allOf": [ + { + "if": { "properties": { "name": { "const": "keepAlive" } } }, + "then": { "properties": { "value": { "type": "boolean" } } } + }, + { + "if": { "properties": { "name": { "const": "keepAliveMsecs" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + }, + { + "if": { "properties": { "name": { "const": "maxFreeSockets" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + }, + { + "if": { "properties": { "name": { "const": "maxSockets" } } }, + "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } + } + ] + }, + "minItems": 1 + } + } +} \ No newline at end of file diff --git a/src/schema/latest/consumer_schema.json b/src/schema/latest/consumer_schema.json index a631f322..969a443e 100644 --- a/src/schema/latest/consumer_schema.json +++ b/src/schema/latest/consumer_schema.json @@ -145,47 +145,10 @@ "title": "Custom Opts (Client Library Dependent)", "description": "Additional options for use by consumer client library. Refer to corresponding consumer lib documentation for acceptable keys and values." , "type": "array", - "items": { - "properties": { - "name": { - "description": "Name of the option", - "type": "string", - "f5expand": true, - "minLength": 1 - }, - "value": { - "description": "Value of the option", - "minLength": 1, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "allOf": [ - { - "f5expand": true - }, - { - "$ref": "base_schema.json#/definitions/stringOrSecret" - } - ] - } - ] - } - }, - "required": [ - "name", - "value" - ], - "additionalProperties": false - }, - "minItems": 1 + "allOf": [ { "$ref": "common_schema.json#/definitions/additionalOptions" } ] }, "format": { - "$comment": "Required for certain consumers: Splunk and Azure_Log_Analytics", + "$comment": "Required for certain consumers: Splunk, Azure_Log_Analytics, Kafka", "title": "Format (informs consumer additional formatting may be required)", "description": "", "type": "string" @@ -689,26 +652,9 @@ } }, "customHttpOpts": { - "items": { - "allOf": [ - { - "if": { "properties": { "name": { "const": "keepAlive" } } }, - "then": { "properties": { "value": { "type": "boolean" } } } - }, - { - "if": { "properties": { "name": { "const": "keepAliveMsecs" } } }, - "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } - }, - { - "if": { "properties": { "name": { "const": "maxSockets" } } }, - "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } - }, - { - "if": { "properties": { "name": { "const": "maxFreeSockets" } } }, - "then": { "properties": { "value": { "type": "integer", "minimum": 0 } } } - } - ] - } + "title": "Custom Http Options", + "description": "Additional options to customize Http requests", + "allOf": [ { "$ref": "common_schema.json#/definitions/httpAgentOptions" } ] }, "otelExporter": { "$comment": "Required for certain consumers: OpenTelemetry_Exporter", @@ -716,6 +662,19 @@ "description": "" , "type": "string", "enum": ["grpc", "json", "protobuf" ] + }, + "partitionerType": { + "$comment": "Required for certain consumers: Kafka", + "title": "Partitioner Type", + "description": "The type of partitioning strategy to use for the consumer" , + "type": "string" + }, + "partitionKey": { + "$comment": "Required for certain consumers: Kafka", + "title": "Partition Key", + "description": "The identifier to use for keyed partitions" , + "type": "string", + "minLength": 1 } }, "allOf": [ @@ -856,7 +815,9 @@ "metricsPath": {}, "service": {}, "convertBooleansToMetrics": {}, - "exporter": {} + "exporter": {}, + "partitionerType": {}, + "partitionKey": {} }, "additionalProperties": false, "dependencies": { @@ -942,7 +903,13 @@ "passphrase": { "$ref": "base_schema.json#/definitions/secret" }, "format": { "$ref": "#/definitions/format", "enum": [ "default", "legacy", "multiMetric" ], "default": "default" }, "proxy": { "$ref": "base_schema.json#/definitions/proxy" }, - "compressionType": { "$ref": "#/definitions/compressionType", "default": "gzip" } + "compressionType": { "$ref": "#/definitions/compressionType", "default": "gzip" }, + "customOpts": { + "allOf": [ + { "$ref": "#/definitions/customOpts" }, + { "$ref": "#/definitions/customHttpOpts" } + ] + } } }, "else": {} @@ -1156,10 +1123,29 @@ ], "properties": { "authenticationProtocol": { "$ref": "#/definitions/authenticationProtocol", "default": "None" }, - "host": { "$ref": "#/definitions/host" }, + "customOpts": { "$ref": "#/definitions/customOpts" }, + "format": { "$ref": "#/definitions/format", "enum": [ "default", "split" ], "default": "default" }, + "host": { + "anyOf": [ + { + "$ref": "#/definitions/host" + }, + { + "type": "array", + "description": "List FQDNs or IP addresses to use as hosts" , + "minItems": 1, + "items": { + "allOf": [{ + "$ref": "#/definitions/host" + }] + } + } + ] + }, + "partitionerType": { "$ref": "#/definitions/partitionerType", "enum": ["default", "random", "cyclic", "keyed" ], "default": "default" }, "protocol": { "$ref": "#/definitions/protocols", "default": "binaryTcpTls" }, "port": { "$ref": "#/definitions/port", "default": 9092 }, - "topic": { "$ref": "#/definitions/topic" } + "topic": { "$ref": "#/definitions/topic" } }, "allOf": [ { @@ -1197,6 +1183,23 @@ } }, "else": {} + }, + { + "if": { "properties": { "partitionerType": { "const": "keyed" } } }, + "then": { + "required": [ "partitionKey" ], + "properties": { + "partitionKey": { "$ref": "#/definitions/partitionKey" } + } + }, + "else": {} + }, + { + "if": { "properties": { "partitionerType": { "not": { "const": "keyed" } } } }, + "then": { + "not": { "required": [ "partitionKey" ] } + }, + "else": {} } ] }, @@ -1380,8 +1383,7 @@ "serviceAccount": { "$ref": "#/definitions/serviceAccount" }, "targetAudience": { "$ref": "#/definitions/targetAudience" }, "useSSL": { "$ref": "#/definitions/useSSL", "default": true } - }, - "nodeSupportVersion": "8.11.1" + } }, "else": {} }, @@ -1432,7 +1434,6 @@ "clientCertificate": ["privateKey"], "privateKey": ["clientCertificate"] }, - "nodeSupportVersion": "8.11.1", "allOf": [ { "if": { "properties": { "exporter": { "const": "grpc" } } } , diff --git a/src/schema/latest/controls_schema.json b/src/schema/latest/controls_schema.json index c990a93c..0ecab244 100644 --- a/src/schema/latest/controls_schema.json +++ b/src/schema/latest/controls_schema.json @@ -22,7 +22,7 @@ "title": "Logging Level", "description": "", "type": "string", - "default": "debug", + "default": "info", "enum": [ "verbose", "debug", @@ -107,10 +107,10 @@ }, "provisionedMemory": { "title": "Provisioned Memory for Application (in MB.)", - "description": "Amount of Memory in MB. that application should not exceed. Once limit exceed, processing may temporarily cease until levels return below threshold. Defalts to 1400 MB.", + "description": "Amount of Memory in MB. that application should not exceed. Once limit exceed, processing may temporarily cease until levels return below threshold. Defaults to the 'runtime.maxHeapSize' value. Maximum should not exceed 'runtime.maxHeapSize'.", "type": "integer", "minimum": 1, - "maximum": 1400 + "heapSizeLimitCheck": true }, "thresholdReleasePercent": { "title": "Memory Usage Threshold Release (Percentage of Available Threshold Memory)", @@ -149,6 +149,14 @@ "type": "number", "minimum": 1400, "default": 1400 + }, + "httpTimeout": { + "title": "Increases the timeout value in seconds for incoming REST API HTTP requests. EXPERIMENTAL!", + "description": "Increases the timeout value in seconds for incoming REST API HTTP requests that allows Telemetry Streaming to avoid TimeoutException error for long lasting operations. Defaults to 60 seconds. EXPERIMENTAL!", + "type": "number", + "default": 60, + "minimum": 60, + "maximum": 600 } }, "additionalProperties": false, diff --git a/src/schema/latest/ihealth_poller_schema.json b/src/schema/latest/ihealth_poller_schema.json index d5bcb9cf..0e6969a4 100644 --- a/src/schema/latest/ihealth_poller_schema.json +++ b/src/schema/latest/ihealth_poller_schema.json @@ -9,7 +9,7 @@ "title": "Time in HH:MM, 24hr", "description": "", "type": "string", - "pattern": "^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]?$" + "pattern": "^([0-9]|0[0-9]|1[0-9]|2[0-3]):([0-9]|[0-5][0-9])?$" }, "iHealthPoller": { "$comment": "system_schema.json should be updated when new property added", @@ -63,7 +63,7 @@ "$ref": "base_schema.json#/definitions/secret" }, "downloadFolder": { - "title": "Directory to download Qkview to", + "title": "Directory to download/generate Qkview file", "description": "", "type": "string", "minLength": 1, diff --git a/src/schema/latest/system_poller_schema.json b/src/schema/latest/system_poller_schema.json index dcb3a454..5de36d6a 100644 --- a/src/schema/latest/system_poller_schema.json +++ b/src/schema/latest/system_poller_schema.json @@ -84,6 +84,29 @@ "$ref": "endpoints_schema.json#/definitions/endpointsObjectRef" } ] + }, + "workers": { + "title": "Worker Count", + "description": "Number of workers to create, which affects processing of simultaneous requests to device.", + "type": "integer", + "default": 5, + "minimum": 1 + }, + "chunkSize": { + "title": "Chunk Size", + "description": "The maximum number of items to fetch at a time for a given endpoint. Requests with reduced response size can help improve CPU/memory utilization.", + "type": "integer", + "default": 30, + "minimum": 1 + }, + "httpAgentOpts": { + "title": "Http Agent Opts", + "description": "Additional http agent options to use", + "type": "array", + "allOf": [ + { "$ref": "common_schema.json#/definitions/additionalOptions" }, + { "$ref": "common_schema.json#/definitions/httpAgentOptions" } + ] } }, "oneOf": [ @@ -137,7 +160,10 @@ "interval": {}, "tag": {}, "actions": {}, - "endpointList": {} + "endpointList": {}, + "workers": {}, + "chunkSize": {}, + "httpAgentOpts": {} }, "additionalProperties": false }, @@ -154,6 +180,9 @@ "required": [ "class" ], + "dependencies": { + "passphrase": [ "username" ] + }, "properties": { "class": { "title": "Class", @@ -228,7 +257,10 @@ "username": {}, "passphrase": {}, "actions": {}, - "endpointList": {} + "endpointList": {}, + "workers": {}, + "chunkSize": {}, + "httpAgentOpts": {} }, "additionalProperties": false }, diff --git a/src/schema/latest/system_schema.json b/src/schema/latest/system_schema.json index cba58faa..28811496 100644 --- a/src/schema/latest/system_schema.json +++ b/src/schema/latest/system_schema.json @@ -11,6 +11,9 @@ "required": [ "class" ], + "dependencies": { + "passphrase": [ "username" ] + }, "properties": { "class": { "title": "Class", diff --git a/test/functional/cloud/awsTests.js b/test/functional/cloud/awsTests.js index 5a4b95a5..8a390046 100644 --- a/test/functional/cloud/awsTests.js +++ b/test/functional/cloud/awsTests.js @@ -176,9 +176,9 @@ describe('AWS Cloud-based Tests', () => { harness.bigip.forEach((bigip) => it( `should fetch system poller data via debug endpoint - ${bigip.name}`, - () => bigip.telemetry.getSystemPollerData('My_System') + () => bigip.telemetry.getSystemPollerData('My_System', 'SystemPoller_1') .then((data) => { - metricDimensions[bigip.hostname] = awsSrcUtil.getDefaultDimensions(data[0]); + metricDimensions[bigip.hostname] = awsSrcUtil.getDefaultDimensions(data); }) )); diff --git a/test/functional/consumersTests/azureApplicationInsightsTests.js b/test/functional/consumersTests/azureApplicationInsightsTests.js index 73af4edb..27fa74de 100644 --- a/test/functional/consumersTests/azureApplicationInsightsTests.js +++ b/test/functional/consumersTests/azureApplicationInsightsTests.js @@ -118,11 +118,11 @@ function test() { } return declaration; }); + + it('sleep for 60sec while Azure AI consumer is not ready', () => promiseUtils.sleep(60000)); }); describe('System Poller data', () => { - it('sleep for 60sec while AI API is not ready', () => promiseUtils.sleep(60000)); - harness.bigip.forEach((bigip) => { it(`should check Azure AI for system poller data - ${bigip.name}`, () => { const apiInfo = getAppInsightAPIInfo(bigip.name); diff --git a/test/functional/consumersTests/azureLogAnalyticsTests.js b/test/functional/consumersTests/azureLogAnalyticsTests.js index 3aa879da..97fe6d02 100644 --- a/test/functional/consumersTests/azureLogAnalyticsTests.js +++ b/test/functional/consumersTests/azureLogAnalyticsTests.js @@ -91,7 +91,10 @@ function test() { }); testUtils.shouldConfigureTS(harness.bigip, () => miscUtils.deepCopy(consumerDeclaration)); - testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${AZURE_LA_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"`); + + it('sleep for 60sec while Azure LA consumer is not ready', () => promiseUtils.sleep(60000)); + + testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${AZURE_LA_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n`); }); describe('Event Listener data', () => { diff --git a/test/functional/consumersTests/elasticsearchTests.js b/test/functional/consumersTests/elasticsearchTests.js index 0b6b195e..f769679e 100644 --- a/test/functional/consumersTests/elasticsearchTests.js +++ b/test/functional/consumersTests/elasticsearchTests.js @@ -219,7 +219,7 @@ function testVer(elsVer) { return decl; }); - testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${ES_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"`); + testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${ES_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n`); }); /** diff --git a/test/functional/consumersTests/f5CloudTests.js b/test/functional/consumersTests/f5CloudTests.js index 43ee63c0..62e1b8e9 100644 --- a/test/functional/consumersTests/f5CloudTests.js +++ b/test/functional/consumersTests/f5CloudTests.js @@ -171,7 +171,7 @@ function setup() { // OpenTelemetry Exporter consumer is supported on bigip 14.1 and above SHOULD_SKIP_DUE_VERSION[bigip.hostname] = srcMiscUtils.compareVersionStrings(version, '<', '14.0'); - logger.info('DUT\' version', { + logger.info('DUT version', { hostname: bigip.hostname, shouldSkipTests: SHOULD_SKIP_DUE_VERSION[bigip.hostname], version @@ -227,7 +227,7 @@ function test() { : null)); testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => (isValidDut(bigip) - ? `functionalTestMetric="147",EOCTimestamp="1231232",hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${F5_CLOUD_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"` + ? `functionalTestMetric="147",EOCTimestamp="1231232",hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${F5_CLOUD_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n` : null)); }); diff --git a/test/functional/consumersTests/fluentdTests.js b/test/functional/consumersTests/fluentdTests.js index 61243020..585b079f 100644 --- a/test/functional/consumersTests/fluentdTests.js +++ b/test/functional/consumersTests/fluentdTests.js @@ -153,7 +153,7 @@ function test() { }); testUtils.shouldConfigureTS(harness.bigip, () => miscUtils.deepCopy(consumerDeclaration)); - testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${FLUENTD_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"`); + testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${FLUENTD_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n`); }); /** diff --git a/test/functional/consumersTests/googleCloudMonitoringTests.js b/test/functional/consumersTests/googleCloudMonitoringTests.js index e1ff50f3..d4cc2aef 100644 --- a/test/functional/consumersTests/googleCloudMonitoringTests.js +++ b/test/functional/consumersTests/googleCloudMonitoringTests.js @@ -93,6 +93,8 @@ function test() { }); testUtils.shouldConfigureTS(harness.bigip, () => miscUtils.deepCopy(consumerDeclaration)); + + it('sleep for 60sec while Google Cloud Monitoring consumer is not ready', () => promiseUtils.sleep(60000)); }); describe('System Poller data', () => { diff --git a/test/functional/consumersTests/kafkaTests.js b/test/functional/consumersTests/kafkaTests.js index a06d0aa4..82a23621 100644 --- a/test/functional/consumersTests/kafkaTests.js +++ b/test/functional/consumersTests/kafkaTests.js @@ -16,19 +16,39 @@ 'use strict'; +/* eslint-disable import/order */ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); -const kafka = require('kafka-node'); + +chai.use(chaiAsPromised); +const assert = chai.assert; const constants = require('../shared/constants'); const harnessUtils = require('../shared/harness'); -const logger = require('../shared/utils/logger').getChild('kafkaTests'); const miscUtils = require('../shared/utils/misc'); const promiseUtils = require('../shared/utils/promise'); const testUtils = require('../shared/testUtils'); +const logger = require('../shared/utils/logger').getChild('kafkaTests'); -chai.use(chaiAsPromised); -const assert = chai.assert; +// +// uncomment section below to enable a much more verbose logging from the kafka-node lib + +// const kafkaLogging = require('kafka-node/logging'); + +// function customLoggerProvider() { +// const customLogger = logger.getChild('kafkaNodeClient'); +// return { +// debug: customLogger.debug.bind(console), +// info: customLogger.info.bind(console), +// warn: customLogger.warning.bind(console), +// error: customLogger.error.bind(console) +// }; +// } + +// kafkaLogging.setLoggerProvider(customLoggerProvider); +// + +const kafka = require('kafka-node'); /** * @module test/functional/consumersTests/kafka @@ -42,15 +62,24 @@ const KAFKA_CONSUMER_NAME = 'Consumer_Kafka'; const KAFKA_PORT = 9092; const KAFKA_PROTOCOL = 'binaryTcp'; const KAFKA_TOPIC = 'f5-telemetry'; +const KAFKA_FORMAT = 'split'; +const KAFKA_PARTITIONER_TYPE = 'cyclic'; const KAFKA_TIMEOUT = 2000; -const ZOOKEEPER_CLIENT_PORT = 2181; const DOCKER_CONTAINERS = { Kafka: { detach: true, env: { - ALLOW_PLAINTEXT_LISTENER: 'yes', - KAFKA_ADVERTISED_LISTENERS: null, - KAFKA_ZOOKEEPER_CONNECT: null + // KRaft settings + KAFKA_CFG_NODE_ID: 0, + KAFKA_BROKER_ID: 0, + KAFKA_CFG_PROCESS_ROLES: 'controller,broker', + KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: `0@127.0.0.1:2${KAFKA_PORT}`, + KAFKA_CFG_LISTENERS: `PLAINTEXT_HOST://:${KAFKA_PORT},CONTROLLER://:2${KAFKA_PORT}`, + KAFKA_CFG_ADVERTISED_LISTENERS: `PLAINTEXT_HOST://kafka-server:${KAFKA_PORT}`, + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT', + KAFKA_CFG_CONTROLLER_LISTENER_NAMES: 'CONTROLLER', + KAFKA_CFG_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT_HOST', + KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: true }, image: `${constants.ARTIFACTORY_DOCKER_HUB_PREFIX}bitnami/kafka:latest`, name: 'kafka-server', @@ -58,19 +87,6 @@ const DOCKER_CONTAINERS = { [KAFKA_PORT]: KAFKA_PORT }, restart: 'always' - }, - Zookeeper: { - detach: true, - env: { - ALLOW_ANONYMOUS_LOGIN: 'yes', - ZOOKEEPER_CLIENT_PORT - }, - image: `${constants.ARTIFACTORY_DOCKER_HUB_PREFIX}bitnami/zookeeper:latest`, - name: 'zookeeper-server', - publish: { - [ZOOKEEPER_CLIENT_PORT]: ZOOKEEPER_CLIENT_PORT - }, - restart: 'always' } }; @@ -242,9 +258,8 @@ let CONTAINERS_STARTED; function setup() { describe('Consumer Setup: Kafka', () => { const cs = harnessUtils.getDefaultHarness().other[0]; - - DOCKER_CONTAINERS.Kafka.env.KAFKA_ADVERTISED_LISTENERS = `PLAINTEXT://${cs.host.host}:${KAFKA_PORT}`; - DOCKER_CONTAINERS.Kafka.env.KAFKA_ZOOKEEPER_CONNECT = `${cs.host.host}:${ZOOKEEPER_CLIENT_PORT}`; + // replace with ip accessible outside of docker network + DOCKER_CONTAINERS.Kafka.env.KAFKA_CFG_ADVERTISED_LISTENERS = `PLAINTEXT_HOST://${cs.host.host}:${KAFKA_PORT}`; describe('Clean-up TS before service configuration', () => { harnessUtils.getDefaultHarness() @@ -270,8 +285,7 @@ function setup() { () => harnessUtils.docker.stopAndRemoveContainer(cs.docker, DOCKER_CONTAINERS[serviceName].name) )); - // order matters - ['Zookeeper', 'Kafka'].forEach((serviceName) => it( + Object.keys(DOCKER_CONTAINERS).forEach((serviceName) => it( `should start new ${serviceName} docker container`, () => harnessUtils.docker.startNewContainer(cs.docker, DOCKER_CONTAINERS[serviceName]) .then(() => CONTAINERS_STARTED.push(true)) @@ -291,7 +305,7 @@ function test() { let kafkaClient = null; before(() => { - assert.isOk(CONTAINERS_STARTED, 'should start Kafka and Zookeeper containers!'); + assert.isOk(CONTAINERS_STARTED, 'should start Kafka container(s)!'); }); describe('Connect to Kafka server', () => { @@ -304,7 +318,7 @@ function test() { }, (err) => client.close() .then(() => { - logger.error('Unable to connect to Kafka and Zookeeper. Going to sleep for 2sec and re-try:', err); + logger.error('Unable to connect to Kafka broker. Going to sleep for 2sec and re-try:', err); return promiseUtils.sleepAndReject(2000, err); }) ); @@ -325,12 +339,14 @@ function test() { protocol: KAFKA_PROTOCOL, port: KAFKA_PORT, topic: KAFKA_TOPIC, - authenticationProtocol: KAFKA_AUTH_PROTOCOL + authenticationProtocol: KAFKA_AUTH_PROTOCOL, + format: KAFKA_FORMAT, + partitionerType: KAFKA_PARTITIONER_TYPE }; }); testUtils.shouldConfigureTS(harness.bigip, () => miscUtils.deepCopy(consumerDeclaration)); - testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${KAFKA_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"`); + testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${KAFKA_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n`); }); describe('Event Listener data', () => { diff --git a/test/functional/consumersTests/openTelemetryExporterTests.js b/test/functional/consumersTests/openTelemetryExporterTests.js index 51179a06..c34ea9d9 100644 --- a/test/functional/consumersTests/openTelemetryExporterTests.js +++ b/test/functional/consumersTests/openTelemetryExporterTests.js @@ -39,7 +39,7 @@ const assert = chai.assert; const MODULE_REQUIREMENTS = { DOCKER: true }; const OTEL_EXPORTERS = [ - 'grpc', + // 'grpc', // disable - HTTP2 + grpc is broken on node 8.11.1 again 'json', 'protobuf' ]; @@ -75,7 +75,7 @@ service: exporters: [prometheus] telemetry: logs: - level: "verbose" + level: "debug" `; const DOCKER_CONTAINERS = { @@ -159,7 +159,7 @@ function setup() { // OpenTelemetry Exporter consumer is supported on bigip 14.1 and above SHOULD_SKIP_DUE_VERSION[bigip.hostname] = srcMiscUtils.compareVersionStrings(version, '<', '14.1'); - logger.info('DUT\' version', { + logger.info('DUT version', { hostname: bigip.hostname, shouldSkipTests: SHOULD_SKIP_DUE_VERSION[bigip.hostname], version @@ -217,7 +217,7 @@ function test() { : null)); testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => (isValidDut(bigip) - ? `functionalTestMetric="147",EOCTimestamp="1231232",hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${OTEL_COLLECTOR_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}",exporter="${exporter}"` + ? `functionalTestMetric="147",EOCTimestamp="1231232",hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${OTEL_COLLECTOR_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}",exporter="${exporter}"\n` : null)); }); diff --git a/test/functional/consumersTests/splunkTests.js b/test/functional/consumersTests/splunkTests.js index ce2641f9..d36ce032 100644 --- a/test/functional/consumersTests/splunkTests.js +++ b/test/functional/consumersTests/splunkTests.js @@ -318,7 +318,7 @@ function testsForSuite(testSetup) { }); testUtils.shouldConfigureTS(harness.bigip, () => miscUtils.deepCopy(consumerDeclaration)); - testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${SPLUNK_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"`); + testUtils.shouldSendListenerEvents(harness.bigip, (bigip, proto, port, idx) => `hostname="${bigip.hostname}",testDataTimestamp="${testDataTimestamp}",test="true",testType="${SPLUNK_CONSUMER_NAME}",protocol="${proto}",msgID="${idx}"\n`); }); /** diff --git a/test/functional/consumersTests/statsdTests.js b/test/functional/consumersTests/statsdTests.js index 61bfff3b..b25b4979 100644 --- a/test/functional/consumersTests/statsdTests.js +++ b/test/functional/consumersTests/statsdTests.js @@ -265,9 +265,9 @@ function test() { harness.bigip.forEach((bigip) => it( `should fetch system poller data via debug endpoint - ${bigip.name}`, - () => bigip.telemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME) + () => bigip.telemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1') .then((data) => { - sysPollerMetricNames[bigip.hostname] = getMetricNames(data[0]); + sysPollerMetricNames[bigip.hostname] = getMetricNames(data); }) )); diff --git a/test/functional/deployment/declaration.yml b/test/functional/deployment/declaration.yml index e8b9e9ea..1e021a1d 100644 --- a/test/functional/deployment/declaration.yml +++ b/test/functional/deployment/declaration.yml @@ -17,20 +17,6 @@ request: name: ubuntu image: Ubuntu18.04LTS-pristine flavor: m1.large - - harness: - _copy: request/hypervisor - name: bigip_13_1 - software: - _copy: request/software/bigip_13_1 - type: bigip - flavor: F5-BIGIP-small - - harness: - _copy: request/hypervisor - name: bigip_14_1 - software: - _copy: request/software/bigip_14_1 - type: bigip - flavor: F5-BIGIP-small - harness: _copy: request/hypervisor name: bigip_15_1 @@ -45,13 +31,6 @@ request: _copy: request/software/bigip_16_1 type: bigip flavor: F5-BIGIP-small - - harness: - _copy: request/hypervisor - name: bigip_17_0 - software: - _copy: request/software/bigip_17_0 - type: bigip - flavor: F5-BIGIP-small - harness: _copy: request/hypervisor name: bigip_17_1 @@ -61,24 +40,6 @@ request: flavor: F5-BIGIP-small hypervisor: VIO software: - bigip_13_1: - default: - force: false - image: BIGIP-13.0.0.0.0.1650 - desired: - branch: '' - build: 0.0.32 - iso_file: '' - version: 13.1.5 - bigip_14_1: - default: - force: false - image: BIGIP-13.0.0.0.0.1650 - desired: - branch: '' - build: 0.0.8 - iso_file: '' - version: 14.1.4.6 bigip_15_1: default: force: false @@ -97,15 +58,6 @@ request: build: 0.0.28 iso_file: '' version: 16.1.2.2 - bigip_17_0: - default: - force: false - image: BIGIP-17.0.0-0.0.22 - desired: - branch: '' - build: 0.0.22 - iso_file: '' - version: 17.0.0 bigip_17_1: default: force: false diff --git a/test/functional/dutTests.js b/test/functional/dutTests.js index 59fb1a7e..b6856de7 100644 --- a/test/functional/dutTests.js +++ b/test/functional/dutTests.js @@ -521,14 +521,11 @@ function test() { // wait 0.5s in case if config was not applied yet return promiseUtils.sleep(500); }) - .then(() => bigip.telemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => bigip.telemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'My_System_Poller')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); assert.isNotEmpty(data, 'should have at least one element'); // verify that 'system' key and child objects are included - data = data[0]; - const snmpName = 'hrDeviceStatus.196608'; assert.isString(data.hrDeviceStatusOrigin[snmpName], 'should not convert SNMP enum to metric'); @@ -609,13 +606,11 @@ function test() { // wait 500ms in case if config was not applied yet it('should get response from systempoller endpoint', () => promiseUtils.sleep(500) - .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); assert.isNotEmpty(data, 'should have at least one element'); // read schema and validate data - data = data[0]; const schema = miscUtils.readJsonFile(constants.DECL.SYSTEM_POLLER_SCHEMA); const valid = miscUtils.validateAgainstSchema(data, schema); if (valid !== true) { @@ -633,13 +628,11 @@ function test() { // wait 0.5s in case if config was not applied yet return promiseUtils.sleep(500); }) - .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); assert.isNotEmpty(data, 'should have at least one element'); // verify that certain data was filtered out, while other data was preserved - data = data[0]; assert.deepStrictEqual(Object.keys(data.system).indexOf('provisioning'), -1); assert.deepStrictEqual(Object.keys(data.system.diskStorage).indexOf('/usr'), -1); assert.notStrictEqual(Object.keys(data.system.diskStorage).indexOf('/'), -1); @@ -655,13 +648,11 @@ function test() { // wait 0.5s in case if config was not applied yet return promiseUtils.sleep(500); }) - .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); assert.isNotEmpty(data, 'should have at least one element'); // verify /var is included with, with 1_tagB removed - data = data[0]; assert.notStrictEqual(Object.keys(data.system.diskStorage).indexOf('/var'), -1); assert.deepStrictEqual(data.system.diskStorage['/var']['1_tagB'], { '1_valueB_1': 'value1' }); // verify /var/log is included with, with 1_tagB included @@ -677,13 +668,11 @@ function test() { // wait 0.5s in case if config was not applied yet return promiseUtils.sleep(500); }) - .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); assert.isNotEmpty(data, 'should have at least one element'); // verify that 'system' key and child objects are included - data = data[0]; assert.deepStrictEqual(Object.keys(data), ['system']); assert.ok(Object.keys(data.system).length > 1); // verify that 'system.diskStorage' is NOT excluded @@ -698,17 +687,16 @@ function test() { // wait 0.5s in case if config was not applied yet return promiseUtils.sleep(500); }) - .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME)) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_1')) + .then((data) => { + bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); + assert.notStrictEqual(data.custom_ipOther, undefined); + assert.notStrictEqual(data.custom_dns, undefined); + }) + .then(() => namespaceTelemetry.getSystemPollerData(constants.DECL.SYSTEM_NAME, 'SystemPoller_2')) .then((data) => { bigip.logger.info(`SystemPoller "${constants.DECL.SYSTEM_NAME}" response:`, { data }); - assert.isArray(data, 'should be array'); - assert.deepStrictEqual(data.length, 2, 'should have two elements'); - - const pollerOneData = data[0]; - const pollerTwoData = data[1]; - assert.notStrictEqual(pollerOneData.custom_ipOther, undefined); - assert.notStrictEqual(pollerOneData.custom_dns, undefined); - assert.ok(pollerTwoData.custom_provisioning.items.length > 0); + assert.ok(data.custom_provisioning.items.length > 0); })); }); }); diff --git a/test/functional/shared/connectors/telemetryConnector.js b/test/functional/shared/connectors/telemetryConnector.js index e333c50b..47dad09b 100644 --- a/test/functional/shared/connectors/telemetryConnector.js +++ b/test/functional/shared/connectors/telemetryConnector.js @@ -171,7 +171,9 @@ class TelemetryStreamingConnector { } poller = poller ? `/${poller}` : ''; return this.icontrol.makeRequestWithAuth({ - method: 'GET', + body: {}, + json: true, + method: 'POST', retry, uri: this.buildURI(`systempoller/${system}${poller}`) }); diff --git a/test/unit/activityRecorderTests.js b/test/unit/activityRecorderTests.js deleted file mode 100644 index 7020fe1d..00000000 --- a/test/unit/activityRecorderTests.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const dummies = require('./shared/dummies'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); - -const ActivityRecorder = sourceCode('src/lib/activityRecorder'); -const configWorker = sourceCode('src/lib/config'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); - -moduleCache.remember(); - -describe('Activity Recorder', () => { - let coreStub; - let recorder; - - const declarationTracerFile = '/var/log/restnoded/telemetryDeclarationHistory'; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - coreStub = stubs.default.coreStub(); - coreStub.persistentStorage.loadData = { config: { } }; - coreStub.utilMisc.generateUuid.numbersOnly = false; - - recorder = new ActivityRecorder(); - return configWorker.cleanup() - .then(() => persistentStorage.persistentStorage.load()); - }); - - afterEach(() => recorder.stop() - .then(() => sinon.restore())); - - describe('.recordDeclarationActivity()', () => { - beforeEach(() => { - recorder.recordDeclarationActivity(configWorker); - }); - - it('should record declaration activity', () => { - coreStub.persistentStorage.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()) - .then(() => coreStub.tracer.waitForData()) - .then(() => { - const data = coreStub.tracer.data[declarationTracerFile]; - assert.lengthOf(data, 4, 'should write 4 events'); - assert.sameDeepMembers( - data.map((d) => d.data.event), - ['received', 'received', 'validationSucceed', 'validationFailed'] - ); - assert.sameDeepMembers( - data.map((d) => d.data.data.declaration.class), - ['Telemetry_Test', 'Telemetry_Test', 'Telemetry', 'Telemetry'] - ); - }); - }); - - it('should mask secrets', () => persistentStorage.persistentStorage.load() - .then(() => configWorker.processDeclaration(dummies.declaration.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }))) - .then(() => coreStub.tracer.waitForData()) - .then(() => { - const data = coreStub.tracer.data[declarationTracerFile]; - assert.sameDeepMembers( - data.map((d) => d.data.data.declaration.consumer.passphrase), - ['*********', '*********'] - ); - })); - }); - - describe('.stop()', () => { - it('should stop', () => recorder.stop() - .then(() => { - assert.includeMatch( - coreStub.logger.messages.debug, - /Terminating\.\.\./ - ); - assert.includeMatch( - coreStub.logger.messages.debug, - /Stopped!/ - ); - })); - - it('should stop declaration tracer', () => { - recorder.recordDeclarationActivity(configWorker); - coreStub.persistentStorage.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()) - .then(() => coreStub.tracer.waitForData()) - .then(() => { - const data = coreStub.tracer.data[declarationTracerFile]; - assert.lengthOf(data, 4, 'should write 4 events'); - return recorder.stop(); - }) - .then(() => configWorker.processDeclaration(dummies.declaration.base.decrypted())) - .then(() => coreStub.tracer.waitForData()) - .then(() => { - const data = coreStub.tracer.data[declarationTracerFile]; - assert.lengthOf(data, 4, 'should not write new events once stopped'); - - assert.includeMatch( - coreStub.logger.messages.debug, - /Terminating declaration tracer/ - ); - }); - }); - }); -}); diff --git a/test/unit/appEventsTests.js b/test/unit/appEventsTests.js new file mode 100644 index 00000000..37fd30ab --- /dev/null +++ b/test/unit/appEventsTests.js @@ -0,0 +1,255 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('./shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('./shared/assert'); +const sourceCode = require('./shared/sourceCode'); +const stubs = require('./shared/stubs'); +const testUtils = require('./shared/util'); + +const AppEvents = sourceCode('src/lib/appEvents'); +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); + +moduleCache.remember(); + +describe('Application Events', () => { + let coreStub; + let events; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ + logger: true + }, { + logger: { + setToVerbose: true, + ignoreLevelChange: false + } + }); + + events = new AppEvents(); + }); + + afterEach(() => { + events.stop(); + sinon.restore(); + }); + + it('should emit events (reuse origin name)', () => { + const t1 = new SafeEventEmitter(); + const spy = sinon.spy(); + + assert.deepStrictEqual(events.registeredEvents, []); + + events.register(t1, 'namespace', ['event1']); + events.on('namespace.event1', () => { + t1.emit('done'); + spy(); + }); + + assert.deepStrictEqual(events.registeredEvents, ['namespace.event1']); + + setTimeout(() => t1.emit('event1'), 10); + return t1.waitFor('done') + .then(() => { + assert.deepStrictEqual(spy.callCount, 1); + assert.includeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.event1" \(event1\)/); + }); + }); + + it('should emit events (name mapping)', () => { + const t1 = new SafeEventEmitter(); + const spy = sinon.spy(); + + assert.deepStrictEqual(events.registeredEvents, []); + events.register(t1, 'namespace', ['event1', { origin: 'proxied' }]); + events.on('namespace.proxied', () => { + t1.emit('done'); + spy(); + }); + + assert.deepStrictEqual(events.registeredEvents, [ + 'namespace.event1', + 'namespace.proxied' + ]); + + setTimeout(() => t1.emit('origin'), 10); + return t1.waitFor('done') + .then(() => { + assert.deepStrictEqual(spy.callCount, 1); + assert.includeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.proxied" \(origin\)/); + }); + }); + + it('should unregister event proxy', () => { + const t1 = new SafeEventEmitter(); + const t2 = new SafeEventEmitter(); + const spy = sinon.spy(); + const off1 = events.register(t1, 'namespace', ['event1', { origin1: 'proxied' }]); + const off2 = events.register(t2, 'namespace', ['event1', { origin2: 'proxied' }]); + const off3 = events.register(t1, 'namespace2', ['event2', { origin3: 'proxied2' }]); + const off4 = events.register(t2, 'namespace2', ['event2', { origin4: 'proxied2' }]); + + events.on('namespace.proxied', () => { + t1.emit('done'); + spy(); + }); + + // t1 + t2 events + assert.deepStrictEqual(events.registeredEvents, [ + 'namespace.event1', + 'namespace.proxied', + 'namespace2.event2', + 'namespace2.proxied2' + ]); + + setTimeout(() => t1.emit('origin1'), 10); + return t1.waitFor('done') + .then(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.proxied" \(origin1\)/); + assert.deepStrictEqual(spy.callCount, 1); + off1.off(); + off3.off(); + + // t2 events left only + assert.deepStrictEqual(events.registeredEvents, [ + 'namespace.event1', + 'namespace.proxied', + 'namespace2.event2', + 'namespace2.proxied2' + ]); + + coreStub.logger.removeAllMessages(); + setTimeout(() => t1.emit('origin2'), 10); + return testUtils.sleep(30); + }) + .then(() => { + off2.off(); + off4.off(); + + assert.deepStrictEqual(events.registeredEvents, []); + + assert.notIncludeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.proxied" \(origin2\)/); + assert.deepStrictEqual(spy.callCount, 1); + assert.doesNotThrow(() => off1.off()); + }); + }); + + it('should remove all listeners', () => { + const t1 = new SafeEventEmitter(); + const spy = sinon.spy(); + + events.register(t1, 'namespace', ['event1', { origin: 'proxied' }]); + events.on('namespace.proxied', () => { + t1.emit('done'); + spy(); + }); + + assert.deepStrictEqual(events.registeredEvents, [ + 'namespace.event1', + 'namespace.proxied' + ]); + + setTimeout(() => t1.emit('origin'), 10); + return t1.waitFor('done') + .then(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.proxied" \(origin\)/); + assert.deepStrictEqual(spy.callCount, 1); + + events.stop(); + coreStub.logger.removeAllMessages(); + setTimeout(() => t1.emit('origin'), 10); + return testUtils.sleep(30); + }) + .then(() => { + assert.deepStrictEqual(events.registeredEvents, []); + assert.notIncludeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.proxied" \(origin\)/); + assert.deepStrictEqual(spy.callCount, 1); + assert.doesNotThrow(() => events.stop()); + }); + }); + + it('should work with wildcards', () => { + const t1 = new SafeEventEmitter(); + const counters = {}; + + events.register(t1, 'namespace', ['event1', 'event2', 'event3.subevent']); + events.on('*.event1', () => { + counters['*.event1'] = (counters['*.event1'] || 0) + 1; + }); + events.on('namespace.event1', () => { + counters['namespace.event1'] = (counters['namespace.event1'] || 0) + 1; + }); + events.on('namespace.*', () => { + counters['namespace.*'] = (counters['namespace.*'] || 0) + 1; + }); + events.on('namespace.*.subevent', () => { + counters['namespace.*.subevent'] = (counters['namespace.*.subevent'] || 0) + 1; + }); + events.on('*.*.subevent', () => { + counters['*.*.subevent'] = (counters['*.*.subevent'] || 0) + 1; + }); + events.on('**.subevent', () => { + counters['**.subevent'] = (counters['**.subevent'] || 0) + 1; + }); + events.on('namespace.**', () => { + counters['namespace.**'] = (counters['namespace.**'] || 0) + 1; + }); + t1.emit('event1'); + t1.emit('event2'); + t1.emit('event3.subevent'); + + return testUtils.sleep(10) + .then(() => { + assert.deepStrictEqual(counters, { + '*.event1': 1, + '**.subevent': 1, + '*.*.subevent': 1, + 'namespace.*': 2, + 'namespace.**': 3, + 'namespace.*.subevent': 1, + 'namespace.event1': 1 + }); + }); + }); + + it('should wait for event', () => { + const t1 = new SafeEventEmitter(); + const spy = sinon.spy(); + + events.register(t1, 'namespace', ['event1', 'done']); + events.on('namespace.event1', () => { + t1.emit('done'); + spy(); + }); + + setTimeout(() => t1.emit('event1'), 10); + return events.waitFor('namespace.done') + .then(() => { + assert.deepStrictEqual(spy.callCount, 1); + assert.includeMatch(coreStub.logger.messages.debug, /Emitting event "namespace\.event1" \(event1\)/); + }); + }); +}); diff --git a/test/unit/configTests.js b/test/unit/configTests.js index 9149a5c5..45ced101 100644 --- a/test/unit/configTests.js +++ b/test/unit/configTests.js @@ -16,7 +16,7 @@ 'use strict'; -/* eslint-disable import/order */ +/* eslint-disable import/order, no-restricted-syntax */ const moduleCache = require('./shared/restoreCache')(); const sinon = require('sinon'); @@ -24,34 +24,35 @@ const sinon = require('sinon'); const assert = require('./shared/assert'); const configTestsData = require('./data/configTestsData'); const dummies = require('./shared/dummies'); +const getComponentTestsData = require('./data/configUtilTests/getComponentsTestsData'); const stubs = require('./shared/stubs'); const sourceCode = require('./shared/sourceCode'); const testUtil = require('./shared/util'); const appInfo = sourceCode('src/lib/appInfo'); -const configWorker = sourceCode('src/lib/config'); -const deviceUtil = sourceCode('src/lib/utils/device'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); -const teemReporter = sourceCode('src/lib/teemReporter'); +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); moduleCache.remember(); describe('Config', () => { + let configWorker; let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = stubs.default.coreStub(); - coreStub.persistentStorage.loadData = { config: { } }; coreStub.utilMisc.generateUuid.numbersOnly = false; - return configWorker.cleanup() - .then(() => persistentStorage.persistentStorage.load()); + + configWorker = coreStub.configWorker.configWorker; + + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -65,7 +66,7 @@ describe('Config', () => { assert.deepStrictEqual(configWorker.currentConfig, { components: [], mappings: {} }); }); - it('should return copy current config', () => { + it('should return copy current config', async () => { const decl = { class: 'Telemetry', My_Consumer: { @@ -93,25 +94,18 @@ describe('Config', () => { namespace: 'f5telemetry_default' }] }; - return configWorker.processDeclaration(decl) - .then(() => { - const conf = configWorker.currentConfig; - assert.deepStrictEqual(conf, expectedNormalized); - conf.components.push(1); - assert.deepStrictEqual(configWorker.currentConfig, expectedNormalized); - }); - }); - }); + await configWorker.processDeclaration(decl); - describe('.teemReporter', () => { - it('should return TeemReporter instance', () => { - assert.instanceOf(configWorker.teemReporter, teemReporter.TeemReporter); + const conf = configWorker.currentConfig; + assert.deepStrictEqual(conf, expectedNormalized); + conf.components.push(1); + assert.deepStrictEqual(configWorker.currentConfig, expectedNormalized); }); }); }); describe('.cleanup()', () => { - it('should cleanup current sate', () => { + it('should cleanup current sate', async () => { const obj = { class: 'Telemetry', My_Consumer: { @@ -130,61 +124,60 @@ describe('Config', () => { trace: false } }; - return configWorker.processDeclaration(obj) - .then(() => { - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { raw: validatedObj }); - assert.deepStrictEqual(configWorker.currentConfig, { - mappings: {}, - components: [{ - class: 'Telemetry_Consumer', - type: 'default', - id: 'f5telemetry_default::My_Consumer', - name: 'My_Consumer', - namespace: 'f5telemetry_default', - traceName: 'f5telemetry_default::My_Consumer', - enable: true, - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::My_Consumer', - type: 'output' - }, - allowSelfSignedCert: false - }] - }); - return configWorker.cleanup(); - }) - .then(() => { - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, undefined); - assert.deepStrictEqual(configWorker.currentConfig, { - components: [], - mappings: {} - }); - }); + await configWorker.processDeclaration(obj); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { raw: validatedObj }); + assert.deepStrictEqual(configWorker.currentConfig, { + mappings: {}, + components: [{ + class: 'Telemetry_Consumer', + type: 'default', + id: 'f5telemetry_default::My_Consumer', + name: 'My_Consumer', + namespace: 'f5telemetry_default', + traceName: 'f5telemetry_default::My_Consumer', + enable: true, + trace: { + enable: false, + encoding: 'utf8', + maxRecords: 10, + path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::My_Consumer', + type: 'output' + }, + allowSelfSignedCert: false + }] + }); + + await configWorker.cleanup(); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, undefined); + assert.deepStrictEqual(configWorker.currentConfig, { + components: [], + mappings: {} + }); }); }); describe('.getDeclaration()', () => { - it('should return BASE_DECLARATION when no data in storage', () => { - coreStub.persistentStorage.loadState = null; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.getDeclaration()) - .then((declaration) => { - assert.deepStrictEqual(declaration, { class: 'Telemetry' }); - }); + it('should return BASE_DECLARATION when no data in storage', async () => { + coreStub.storage.restWorker.loadStateData = null; + + await coreStub.storage.service.restart(); + const declaration = await configWorker.getDeclaration(); + + assert.deepStrictEqual(declaration, { class: 'Telemetry' }); }); - it('should return data even when invalid data in storage', () => { - coreStub.persistentStorage.loadData = { config: { raw: { My_Consumer: { class: 'Telemetry_Consumer' } } } }; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.getDeclaration()) - .then((declaration) => { - assert.deepStrictEqual(declaration, { My_Consumer: { class: 'Telemetry_Consumer' } }); - }); + it('should return data even when invalid data in storage', async () => { + coreStub.storage.restWorker.loadData = { config: { raw: { My_Consumer: { class: 'Telemetry_Consumer' } } } }; + + await coreStub.storage.service.restart(); + const declaration = await configWorker.getDeclaration(); + + assert.deepStrictEqual(declaration, { My_Consumer: { class: 'Telemetry_Consumer' } }); }); - it('should return stored declaration (not expanded)', () => { + it('should return stored declaration (not expanded)', async () => { const decl = { class: 'Telemetry', Shared: { @@ -233,20 +226,21 @@ describe('Config', () => { compressionType: 'none' } }; - return configWorker.processDeclaration(decl) - .then(() => configWorker.getDeclaration()) - .then((declaration) => { - assert.deepStrictEqual(declaration, expectedDeclaration); - }); + + await configWorker.processDeclaration(decl); + const declaration = await configWorker.getDeclaration(); + + assert.deepStrictEqual(declaration, expectedDeclaration); }); - it('should fail when no namespace with such name', () => assert.isRejected( - persistentStorage.persistentStorage.load() - .then(() => configWorker.getDeclaration('namespace')), - /Namespace with name 'namespace' doesn't exist/ - )); + it('should fail when no namespace with such name', async () => { + await assert.isRejected( + configWorker.getDeclaration('namespace'), + /Namespace with name 'namespace' doesn't exist/ + ); + }); - it('should return declaration for particular Namespace (not expanded)', () => { + it('should return declaration for particular Namespace (not expanded)', async () => { const decl = { class: 'Telemetry', My_Namespace: { @@ -297,67 +291,64 @@ describe('Config', () => { compressionType: 'none' } }; - return configWorker.processDeclaration(decl) - .then(() => configWorker.getDeclaration('My_Namespace')) - .then((declaration) => { - assert.deepStrictEqual(declaration, expectedDeclaration); - }); + + await configWorker.processDeclaration(decl); + const declaration = await configWorker.getDeclaration('My_Namespace'); + + assert.deepStrictEqual(declaration, expectedDeclaration); }); }); describe('.loadConfig()', () => { - it('should load BASE_DECLARATION and do not save it when unable to load existing one', () => { - coreStub.persistentStorage.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()) - .then((declaration) => { - assert.deepStrictEqual(declaration, { class: 'Telemetry', schemaVersion: appInfo.version }); - assert.deepStrictEqual(coreStub.configWorker.configs[0], { components: [], mappings: {} }); - assert.deepStrictEqual(configWorker.currentConfig, { components: [], mappings: {} }); - return configWorker.getDeclaration(); - }) - .then((declaration) => { - assert.deepStrictEqual(declaration, { class: 'Telemetry_Test' }); - assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 2, 'should emit "received" event 2 times'); - assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 1, 'should emit "validationFailed" event'); - assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 1, 'should emit "validationSucceed" event'); - - let expectedMetadata = { message: 'Loading saved configuration' }; - assert.deepStrictEqual( - coreStub.configWorker.receivedSpy.args[0][0].metadata, - expectedMetadata - ); - assert.deepStrictEqual( - coreStub.configWorker.validationFailedSpy.args[0][0].metadata, - expectedMetadata - ); - - expectedMetadata = { message: 'Loading default config! Unable to load saved config, see error message in logs' }; - assert.deepStrictEqual( - coreStub.configWorker.receivedSpy.args[1][0].metadata, - expectedMetadata - ); - assert.deepStrictEqual( - coreStub.configWorker.validationSucceedSpy.args[0][0].metadata, - expectedMetadata - ); - }); + it('should load BASE_DECLARATION and do not save it when unable to load existing one', async () => { + coreStub.storage.restWorker.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; + await coreStub.storage.service.restart(); + + let declaration = await configWorker.load(); + assert.deepStrictEqual(declaration, { class: 'Telemetry', schemaVersion: appInfo.version }); + assert.deepStrictEqual(coreStub.configWorker.configs[0], { components: [], mappings: {} }); + assert.deepStrictEqual(configWorker.currentConfig, { components: [], mappings: {} }); + + declaration = await configWorker.getDeclaration(); + assert.deepStrictEqual(declaration, { class: 'Telemetry_Test' }); + assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 2, 'should emit "received" event 2 times'); + assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 1, 'should emit "validationFailed" event'); + assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 1, 'should emit "validationSucceed" event'); + + let expectedMetadata = { message: 'Loading saved configuration' }; + assert.deepStrictEqual( + coreStub.configWorker.receivedSpy.args[0][0].metadata, + expectedMetadata + ); + assert.deepStrictEqual( + coreStub.configWorker.validationFailedSpy.args[0][0].metadata, + expectedMetadata + ); + + expectedMetadata = { message: 'Loading default config! Unable to load saved config, see error message in logs' }; + assert.deepStrictEqual( + coreStub.configWorker.receivedSpy.args[1][0].metadata, + expectedMetadata + ); + assert.deepStrictEqual( + coreStub.configWorker.validationSucceedSpy.args[0][0].metadata, + expectedMetadata + ); }); - it('should load BASE_DECLARATION when no stored declaration', () => { - coreStub.persistentStorage.loadState = null; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()) - .then((declaration) => { - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { raw: { class: 'Telemetry', schemaVersion: appInfo.version } }); - assert.deepStrictEqual(declaration, { class: 'Telemetry', schemaVersion: appInfo.version }); - assert.deepStrictEqual(coreStub.configWorker.configs[0], { components: [], mappings: {} }); - assert.deepStrictEqual(configWorker.currentConfig, { components: [], mappings: {} }); - }); + it('should load BASE_DECLARATION when no stored declaration', async () => { + coreStub.storage.restWorker.loadStateData = null; + await coreStub.storage.service.restart(); + + const declaration = await configWorker.load(); + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { raw: { class: 'Telemetry', schemaVersion: appInfo.version } }); + assert.deepStrictEqual(declaration, { class: 'Telemetry', schemaVersion: appInfo.version }); + assert.deepStrictEqual(coreStub.configWorker.configs[0], { components: [], mappings: {} }); + assert.deepStrictEqual(configWorker.currentConfig, { components: [], mappings: {} }); }); - it('should load stored declaration with an additional options and show save without them', () => { - coreStub.persistentStorage.loadData = { + it('should load stored declaration with an additional options and show save without them', async () => { + coreStub.storage.restWorker.loadData = { config: { raw: { class: 'Telemetry', My_Consumer: { class: 'Telemetry_Consumer', type: 'default' } }, normalized: { mappings: {}, components: [] } @@ -394,83 +385,97 @@ describe('Config', () => { allowSelfSignedCert: false }] }; - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()) - .then((loadedDeclaration) => { - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { - raw: { - class: 'Telemetry', - schemaVersion: appInfo.version, - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default', - allowSelfSignedCert: false, - enable: true, - trace: false - } - } - }); - assert.deepStrictEqual(loadedDeclaration, expectedDeclaration); - assert.deepStrictEqual(coreStub.configWorker.configs[0], expectedConfiguration); - assert.deepStrictEqual(configWorker.currentConfig, expectedConfiguration); - }); + + await coreStub.storage.service.restart(); + + const loadedDeclaration = await configWorker.load(); + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { + raw: { + class: 'Telemetry', + schemaVersion: appInfo.version, + My_Consumer: { + class: 'Telemetry_Consumer', + type: 'default', + allowSelfSignedCert: false, + enable: true, + trace: false + } + } + }); + assert.deepStrictEqual(loadedDeclaration, expectedDeclaration); + assert.deepStrictEqual(coreStub.configWorker.configs[0], expectedConfiguration); + assert.deepStrictEqual(configWorker.currentConfig, expectedConfiguration); }); }); describe('.processDeclaration()', () => { - it('should throw an error if save config fails', () => { - coreStub.persistentStorage.saveError = new Error('saveStateError'); - return assert.isRejected(configWorker.processDeclaration({ class: 'Telemetry' }), /saveStateError/); + it('should ignore an error if save config fails', async () => { + coreStub.storage.restWorker.saveError = new Error('saveStateError'); + await configWorker.processDeclaration({ class: 'Telemetry' }); }); - it('should reject with invalid declaration (no args)', () => assert.isRejected( - configWorker.processDeclaration(), - /should have required property.*class/ - )); + it('should reject with invalid declaration (no args)', async () => { + await assert.isRejected( + configWorker.processDeclaration(), + /should have required property.*class/ + ); + }); - it('should reject with invalid declaration (empty object)', () => assert.isRejected( - configWorker.processDeclaration({}), - /should have required property.*class/ - )); + it('should reject with invalid declaration (empty object)', async () => { + await assert.isRejected( + configWorker.processDeclaration({}), + /should have required property.*class/ + ); + }); }); describe('.processNamespaceDeclaration()', () => { - it('should throw an error if save config fails', () => { - coreStub.persistentStorage.saveError = new Error('saveStateError'); - return assert.isRejected(configWorker.processNamespaceDeclaration({ class: 'Telemetry_Namespace' }, 'namespace'), /saveStateError/); + it('should ignore an error if save config fails', async () => { + coreStub.storage.restWorker.saveError = new Error('saveStateError'); + await configWorker.processNamespaceDeclaration({ class: 'Telemetry_Namespace' }, 'namespace'); }); - it('should reject with invalid declaration (no args)', () => assert.isRejected( - configWorker.processNamespaceDeclaration(), - /should have required property.*class/ - )); + it('should reject with invalid declaration (no args)', async () => { + await assert.isRejected( + configWorker.processNamespaceDeclaration(), + /should have required property.*class/ + ); + }); - it('should reject with invalid declaration (empty object)', () => assert.isRejected( - configWorker.processNamespaceDeclaration({}), - /should have required property.*class/ - )); + it('should reject with invalid declaration (empty object)', async () => { + await assert.isRejected( + configWorker.processNamespaceDeclaration({}), + /should have required property.*class/ + ); + }); - it('should reject with invalid namespace declaration (class is not Telemetry_Namespace)', () => assert.isRejected( - configWorker.processNamespaceDeclaration({ class: 'Telemetry' }), - /properties\/class\/enum.*"allowedValues":\["Telemetry_Namespace"\]/ - )); + it('should reject with invalid namespace declaration (class is not Telemetry_Namespace)', async () => { + await assert.isRejected( + configWorker.processNamespaceDeclaration({ class: 'Telemetry' }), + /properties\/class\/enum.*"allowedValues":\["Telemetry_Namespace"\]/ + ); + }); - it('should reject with invalid namespace declaration (invalid property)', () => assert.isRejected( - configWorker.processNamespaceDeclaration({ - class: 'Telemetry_Namespace', - My_System_1: { - class: 'Telemetry_System' - }, - additionalProp: { fake: true } - }, 'NewbieNamespace'), - /"additionalProperty":"fake".*should NOT have additional properties/ - )); - - it('should reject on attempt to submit Namespace declaration with name that belongs to another type of object', () => assert.isRejected( - configWorker.processDeclaration({ class: 'Telemetry', namespace: { class: 'Shared' } }) - .then(() => configWorker.processNamespaceDeclaration({ class: 'Telemetry_Namespace' }, 'namespace')), - /Unable to override existing object with name "namespace"/ - )); + it('should reject with invalid namespace declaration (invalid property)', async () => { + await assert.isRejected( + configWorker.processNamespaceDeclaration({ + class: 'Telemetry_Namespace', + My_System_1: { + class: 'Telemetry_System' + }, + additionalProp: { fake: true } + }, 'NewbieNamespace'), + /"additionalProperty":"fake".*should NOT have additional properties/ + ); + }); + + it('should reject on attempt to submit Namespace declaration with name that belongs to another type of object', async () => { + await configWorker.processDeclaration({ class: 'Telemetry', namespace: { class: 'Shared' } }); + await assert.isRejected( + configWorker.processNamespaceDeclaration({ class: 'Telemetry_Namespace' }, 'namespace'), + /Unable to override existing object with name "namespace"/ + ); + }); }); describe('.processDeclaration() and .processNamespaceDeclaration() common tests', () => { @@ -513,100 +518,81 @@ describe('Config', () => { let loadPromise = Promise.resolve(); if (testConf.preLoadDeclaration) { historyIndex = 1; - coreStub.persistentStorage.loadData = { + coreStub.storage.restWorker.loadData = { config: { raw: testUtil.deepCopy(testConf.preLoadDeclaration) } }; loadPromise = loadPromise - .then(() => persistentStorage.persistentStorage.load()) + .then(() => coreStub.storage.service.restart()) .then(() => configWorker.load()); } else { - coreStub.persistentStorage.loadState = null; + coreStub.storage.restWorker.loadStateData = null; loadPromise = loadPromise - .then(() => persistentStorage.persistentStorage.load()); + .then(() => coreStub.storage.service.restart()); } return loadPromise .then(() => { - preloadedState = coreStub.persistentStorage.savedState; + preloadedState = coreStub.storage.restWorker.savedState; }); }); - it('should expand config, save it and emit event', () => testSuite.entryPoint() - .then((validated) => { - assert.deepStrictEqual( - coreStub.teemReporter.declarations[historyIndex], - expectedFullDeclaration, - 'should pass whole declaration to TeemReporter' - ); - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { + it('should expand config, save it and emit event', async () => { + const validated = await testSuite.entryPoint(); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { + raw: expectedFullDeclaration + }, 'should store whole declaration in Storage'); + assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); + assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); + assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); + }); + + it('should return expanded declaration', async () => { + const validated = await testSuite.entryPoint({ expanded: true }); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { + raw: expectedFullDeclaration + }, 'should store whole declaration in Storag'); + assert.deepStrictEqual(validated, expectedExpandedDeclaration, 'should match expected expanded declaration'); + assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); + assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); + }); + + if (testConf.additionalTests) { + it('should not fail when unable to distribute configuration (decryptAllSecrets failed)', async () => { + coreStub.deviceUtil.decrypt.rejects(new Error('expected error')); + const validated = await testSuite.entryPoint(); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { raw: expectedFullDeclaration - }, 'should store whole declaration in PersistentStorage'); + }, 'should store whole declaration in Storage'); assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); - assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); - })); - - it('should return expanded declaration', () => testSuite.entryPoint({ expanded: true }) - .then((validated) => { - assert.deepStrictEqual( - coreStub.teemReporter.declarations[historyIndex], - expectedFullDeclaration, - 'should pass whole declaration to TeemReporter' - ); - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { - raw: expectedFullDeclaration - }, 'should store whole declaration in PersistentStorage'); - assert.deepStrictEqual(validated, expectedExpandedDeclaration, 'should match expected expanded declaration'); - assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); - assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); - })); - - if (testConf.additionalTests) { - it('should not fail when unable to distribute configuration (decryptAllSecrets failed)', () => { - deviceUtil.decryptSecret.rejects(new Error('expected error')); - return testSuite.entryPoint() - .then((validated) => { - assert.deepStrictEqual( - coreStub.teemReporter.declarations[historyIndex], - expectedFullDeclaration, - 'should pass whole declaration to TeemReporter' - ); - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { - raw: expectedFullDeclaration - }, 'should store whole declaration in PersistentStorage'); - assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); - assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); - assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], undefined, 'should not emit event'); - }); + assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], undefined, 'should not emit event'); }); - it('should not fail when unable to distribute configuration (event listener failed)', () => { + it('should not fail when unable to distribute configuration (event listener failed)', async () => { const onChangeStub = stubs.eventEmitterListener(configWorker, 'change'); onChangeStub.rejects(new Error('expected error')); - return testSuite.entryPoint() - .then((validated) => { - assert.deepStrictEqual( - coreStub.teemReporter.declarations[historyIndex], - expectedFullDeclaration, - 'should pass whole declaration to TeemReporter' - ); - assert.deepStrictEqual(coreStub.persistentStorage.savedData.config, { - raw: expectedFullDeclaration - }, 'should store whole declaration in PersistentStorage'); - assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); - assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); - assert.isTrue(onChangeStub.calledOnce); - }); + + const validated = await testSuite.entryPoint(); + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.config, { + raw: expectedFullDeclaration + }, 'should store whole declaration in Storage'); + assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); + assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); + assert.isTrue(onChangeStub.calledOnce); }); - it('should not save config', () => testSuite.entryPoint({ save: false }) - .then((validated) => { - assert.deepStrictEqual(coreStub.persistentStorage.savedState, preloadedState, 'should match pre-test state'); - assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); - assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); - assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); - })); + it('should not save config', async () => { + const validated = await testSuite.entryPoint({ save: false }); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedState, preloadedState, 'should match pre-test state'); + assert.deepStrictEqual(validated, expectedValidatedDeclaration, 'should match expected validated declaration'); + assert.deepStrictEqual(configWorker.currentConfig, expectedCurrentConfiguration, 'should match expected current configuration'); + assert.deepStrictEqual(coreStub.configWorker.configs[historyIndex], expectedEmittedConfiguration, 'should match expected emitted configuration'); + }); } }); }); @@ -615,134 +601,146 @@ describe('Config', () => { }); describe('\'error\' event', () => { - it('should log error if caught an error', () => configWorker.safeEmitAsync('error', new Error('expected error')) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Unhandled error in ConfigWorker[\s\S]+expected error/gm, - 'should log error message' - ); - })); + it('should log error if caught an error', async () => { + await configWorker.safeEmitAsync('error', new Error('expected error')); + + assert.includeMatch( + coreStub.logger.messages.all, + /Unhandled error in ConfigWorker[\s\S]+expected error/gm, + 'should log error message' + ); + }); }); describe('\'change\' event', () => { - it('should set log level', () => configWorker.processDeclaration(dummies.declaration.base.decrypted({ - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error' - }) - })) - .then(() => { - assert.deepStrictEqual(coreStub.logger.logLevelHistory.slice(-1), ['error'], 'should set log level to error'); - return configWorker.processDeclaration(dummies.declaration.base.decrypted({ - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'debug' - }) - })); - }) - .then(() => { - assert.deepStrictEqual(coreStub.logger.logLevelHistory.slice(-1), ['debug'], 'should set log level to debug'); + it('should set log level', async () => { + await configWorker.processDeclaration(dummies.declaration.base.decrypted({ + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error' + }) })); + + assert.deepStrictEqual(coreStub.logger.logLevelHistory.slice(-1), ['error'], 'should set log level to error'); + + await configWorker.processDeclaration(dummies.declaration.base.decrypted({ + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'debug' + }) + })); + + assert.deepStrictEqual(coreStub.logger.logLevelHistory.slice(-1), ['debug'], 'should set log level to debug'); + }); }); describe('\'received\' event', () => { - it('should send event', () => configWorker.processDeclaration(dummies.declaration.base.decrypted({ - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error' - }) - }), { - metadata: { - msg: 'here' - } - }) - .then(() => { - assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 1, 'should call listener for "received" event'); - assert.deepStrictEqual(coreStub.configWorker.receivedSpy.args[0], [{ - declaration: dummies.declaration.base.decrypted({ - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error' - }) - }), - metadata: { - msg: 'here' - }, - transactionID: 'uuid1' - }], 'should pass data to event'); - })); + it('should send event', async () => { + await configWorker.processDeclaration(dummies.declaration.base.decrypted({ + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error' + }) + }), { + metadata: { + msg: 'here' + } + }); - it('should send event (namespace)', () => configWorker.processNamespaceDeclaration(dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.default.decrypted() - }), - 'Namespace', - { - metadata: { - msg: 'here' - } - }) - .then(() => { - assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 1, 'should call listener for "received" event'); - assert.deepStrictEqual(coreStub.configWorker.receivedSpy.args[0], [{ - declaration: dummies.declaration.base.decrypted({ - Namespace: dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.default.decrypted() - }) - }), - metadata: { - msg: 'here' - }, - transactionID: 'uuid1' - }], 'should pass data to event'); - })); + assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 1, 'should call listener for "received" event'); + assert.deepStrictEqual(coreStub.configWorker.receivedSpy.args[0], [{ + declaration: dummies.declaration.base.decrypted({ + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error' + }) + }), + metadata: { + msg: 'here' + }, + transactionID: 'uuid1' + }], 'should pass data to event'); + }); + + it('should send event (namespace)', async () => { + await configWorker.processNamespaceDeclaration(dummies.declaration.namespace.base.decrypted({ + consumer: dummies.declaration.consumer.default.decrypted() + }), + 'Namespace', + { + metadata: { + msg: 'here' + } + }); + + assert.deepStrictEqual(coreStub.configWorker.receivedSpy.callCount, 1, 'should call listener for "received" event'); + assert.deepStrictEqual(coreStub.configWorker.receivedSpy.args[0], [{ + declaration: dummies.declaration.base.decrypted({ + Namespace: dummies.declaration.namespace.base.decrypted({ + consumer: dummies.declaration.consumer.default.decrypted() + }) + }), + metadata: { + msg: 'here' + }, + transactionID: 'uuid1' + }], 'should pass data to event'); + }); }); describe('\'validationFailed\' event', () => { - it('should send event', () => { + it('should send event', async () => { let validationError; configWorker.on('received', (ctx) => { // want to be sure that original data distributed only ctx.declaration.modified = true; ctx.metadata.modified = true; }); - return configWorker.processDeclaration(dummies.declaration.base.decrypted({ - invalid: true, - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error' - }) - }), { + + try { + await configWorker.processDeclaration(dummies.declaration.base.decrypted({ + invalid: true, + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error' + }) + }), { + metadata: { + msg: 'here' + } + }); + } catch (error) { + validationError = error; + } + + assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 0, 'should not call listener for "validationSucceed" event'); + assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 1, 'should call listener "validationFailed" event'); + assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.args[0], [{ + declaration: dummies.declaration.base.decrypted({ + invalid: true, + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error' + }) + }), + errorMsg: `${validationError}`, metadata: { msg: 'here' - } - }) - .catch((error) => { - validationError = error; - }) - .then(() => { - assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 0, 'should not call listener for "validationSucceed" event'); - assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 1, 'should call listener "validationFailed" event'); - assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.args[0], [{ - declaration: dummies.declaration.base.decrypted({ - invalid: true, - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error' - }) - }), - errorMsg: `${validationError}`, - metadata: { - msg: 'here' - }, - transactionID: 'uuid1' - }], 'should pass data to event'); - }); + }, + transactionID: 'uuid1' + }], 'should pass data to event'); + + let data; + assert.doesNotThrow(() => { + data = JSON.parse(validationError.message); + }, 'should be valid JSON'); + + assert.isArray(data); }); }); describe('\'validationSucceed\' event', () => { - it('should send event', () => { + it('should send event', async () => { configWorker.on('received', (ctx) => { // want to be sure that original data distributed only ctx.declaration.modified = true; ctx.metadata.modified = true; }); - return configWorker.processDeclaration(dummies.declaration.base.decrypted({ + await configWorker.processDeclaration(dummies.declaration.base.decrypted({ controls: dummies.declaration.controls.full.decrypted({ logLevel: 'error' }) @@ -750,24 +748,246 @@ describe('Config', () => { metadata: { msg: 'here' } - }) - .then(() => { - assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 0, 'should not call listener for "validationFailed" event'); - assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 1, 'should call listener "validationSucceed" event'); - assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.args[0], [{ - declaration: dummies.declaration.base.decrypted({ - controls: dummies.declaration.controls.full.decrypted({ - logLevel: 'error', - memoryThresholdPercent: 90 - }), - schemaVersion: appInfo.version - }), - metadata: { - msg: 'here' - }, - transactionID: 'uuid1' - }], 'should pass data to event'); + }); + + assert.deepStrictEqual(coreStub.configWorker.validationFailedSpy.callCount, 0, 'should not call listener for "validationFailed" event'); + assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.callCount, 1, 'should call listener "validationSucceed" event'); + assert.deepStrictEqual(coreStub.configWorker.validationSucceedSpy.args[0], [{ + declaration: dummies.declaration.base.decrypted({ + controls: dummies.declaration.controls.full.decrypted({ + logLevel: 'error', + memoryThresholdPercent: 90 + }), + schemaVersion: appInfo.version + }), + metadata: { + msg: 'here' + }, + transactionID: 'uuid1' + }], 'should pass data to event'); + }); + }); + + describe('\'*.config.getConfig\' event', () => { + it('should listen for the event', async () => { + const ee = new SafeEventEmitter(); + coreStub.appEvents.appEvents.register(ee, 'test', ['config.getConfig']); + + const getConfig = async (filter) => { + const components = await new Promise((resolve) => { + ee.emit('config.getConfig', resolve, filter); }); + return components.map((c) => ({ id: c.id, class: c.class })); + }; + + assert.deepStrictEqual(await getConfig(), [], 'should return empty array for empty config'); + + await configWorker.processDeclaration(getComponentTestsData.declaration); + + const combinations = testUtil.product( + getComponentTestsData.params.class, + getComponentTestsData.params.filter, + getComponentTestsData.params.name, + getComponentTestsData.params.namespace + ); + + for (const [cls, filter, name, namespace] of combinations) { + const expected = cls.expected + // `filter` ignored === all ids + .filter((c) => getComponentTestsData.allIDs.find((e) => e.id === c.id) + && name.expected.find((e) => e.id === c.id) + && namespace.expected.find((e) => e.id === c.id)); + + assert.sameDeepMembers( + (await getConfig({ + class: cls.filter, + filter: filter.filter, + name: name.filter, + namespace: namespace.filter + })).map((c) => ({ id: c.id, class: c.class })), + expected + ); + } + }); + }); + + describe('\'*.config.decrypt\' event', () => { + let ee; + + const decryptConfig = (data) => new Promise((resolve, reject) => { + ee.emit('config.decrypt', data, (error, decryptedData) => { + if (error) { + reject(error); + } else { + resolve(decryptedData); + } + }); + }); + + const getConfig = () => new Promise((resolve) => { + ee.emit('config.getConfig', resolve); + }); + + beforeEach(async () => { + ee = new SafeEventEmitter(); + coreStub.appEvents.appEvents.register(ee, 'test', [ + 'config.getConfig', + 'config.decrypt' + ]); + + await configWorker.processDeclaration({ + class: 'Telemetry', + My_Consumer: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: '192.168.2.1', + path: '/test', + passphrase: { + cipherText: 'test_passphrase_1' + } + }, + My_Namespace: { + class: 'Telemetry_Namespace', + My_Consumer: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: '192.168.2.1', + path: '/test', + passphrase: { + cipherText: 'test_passphrase_2' + } + } + } + }); + }); + + it('should decrypt config', async () => { + const components = await getConfig(); + assert.isTrue( + components.every((c) => c.passphrase.cipherText.startsWith('$M$')), + 'should encrypt secrets' + ); + + const decrypted = await decryptConfig(components); + assert.sameDeepMembers( + decrypted.map((c) => c.passphrase), + ['test_passphrase_1', 'test_passphrase_2'], + 'should decrypt secrets' + ); + assert.sameDeepMembers( + components.map((c) => c.passphrase), + ['test_passphrase_1', 'test_passphrase_2'], + 'should modify origin data' + ); + }); + + it('should return error when unable to decypt', async () => { + const components = await getConfig(); + assert.isTrue( + components.every((c) => c.passphrase.cipherText.startsWith('$M$')), + 'should encrypt secrets' + ); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt error')); + await assert.isRejected(decryptConfig(components), /expected decrypt error/); + }); + }); + + describe('\'*.config.getHash\' event', () => { + let ee; + + const getHash = (data) => new Promise((resolve, reject) => { + ee.emit('config.getHash', data, (error, hash) => { + if (error) { + reject(error); + } else { + resolve(hash); + } + }); + }); + + const getConfig = () => new Promise((resolve) => { + ee.emit('config.getConfig', resolve); + }); + + beforeEach(async () => { + ee = new SafeEventEmitter(); + coreStub.appEvents.appEvents.register(ee, 'test', [ + 'config.getConfig', + 'config.getHash' + ]); + + await configWorker.processDeclaration({ + class: 'Telemetry', + My_Consumer: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: '192.168.2.1', + path: '/test', + passphrase: { + cipherText: 'test_passphrase_1' + } + }, + My_Namespace: { + class: 'Telemetry_Namespace', + My_Consumer: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: '192.168.2.1', + path: '/test', + passphrase: { + cipherText: 'test_passphrase_2' + } + } + } + }); + }); + + it('should get config hash', async () => { + const components = await getConfig(); + + const hashList = await getHash(components); + assert.isArray(hashList); + hashList.forEach((hash, idx) => { + assert.isString(hash); + assert.isNotEmpty(hash); + + if (idx !== (hashList.length - 1)) { + assert.notInclude(hashList.slice(idx + 1), hash, 'should return different hashes for objects'); + } + }); + + const reversedHashList = await getHash(components.slice().reverse()); + reversedHashList.forEach((hash, idx) => { + assert.deepStrictEqual(hash, hashList[hashList.length - 1 - idx]); + }); + + const componentsCopy = components.slice(); + componentsCopy.forEach((c) => { + c.name = 'test_value'; + }); + + const updatedHashList = await getHash(componentsCopy); + updatedHashList.forEach((hash, idx) => { + assert.isString(hash); + assert.isNotEmpty(hash); + + if (idx !== (updatedHashList.length - 1)) { + assert.notInclude(updatedHashList.slice(idx + 1), hash, 'should return different hashes for objects'); + } + }); + updatedHashList.forEach((hash) => { + assert.notInclude(hashList, hash, 'should generate a new hash value'); + }); + + const singleHash = await getHash(componentsCopy[0]); + assert.isString(singleHash); + assert.isNotEmpty(singleHash); + assert.include(updatedHashList, singleHash); + }); + + it('should return error when unable to get hash', async () => { + await assert.isRejected(getHash(), 'config should be an object'); }); }); }); diff --git a/test/unit/constantsTests.js b/test/unit/constantsTests.js index 6c4700c8..8114e3c2 100644 --- a/test/unit/constantsTests.js +++ b/test/unit/constantsTests.js @@ -28,17 +28,7 @@ const packageInfo = sourceCode('package.json'); moduleCache.remember(); describe('Constants', () => { - before(() => { - moduleCache.restore(); - }); - - /** - * ATTENTION: - * - * If this test failed it worth to check other tests and source code - * that uses that constant(s) - */ - it('constants verification', () => { + function getExpected() { const versionInfo = packageInfo.version.split('-'); if (versionInfo.length === 1) { versionInfo.push('1'); @@ -47,8 +37,7 @@ describe('Constants', () => { assert.isNotEmpty(versionInfo[0]); assert.isNotEmpty(versionInfo[1]); - // TODO: add other constants later - assert.deepStrictEqual(constants, { + return { ACTIVITY_RECORDER: { DECLARATION_TRACER: { MAX_RECORDS: 60, @@ -57,14 +46,7 @@ describe('Constants', () => { }, APP_NAME: 'Telemetry Streaming', APP_THRESHOLDS: { - MONITOR_DISABLED: 'MONITOR_DISABLED', // TODO: delete MEMORY: { - /** TODO: DELETE */ - DEFAULT_MB: 1433, - OK: 'MEMORY_USAGE_OK', - NOT_OK: 'MEMORY_USAGE_HIGH', - /** TODO: DELETE END */ - ARGRESSIVE_CHECK_INTERVALS: [ { usage: 50, interval: 0.5 }, { usage: 60, interval: 0.4 }, @@ -126,6 +108,11 @@ describe('Constants', () => { CONFIG_WORKER: { STORAGE_KEY: 'config' }, + DATA_PIPELINE: { + PULL_EVENT: 0b01, + PUSH_EVENT: 0b10, + PUSH_PULL_EVENT: 0b11 + }, DAY_NAME_TO_WEEKDAY: { monday: 1, tuesday: 2, @@ -136,16 +123,13 @@ describe('Constants', () => { sunday: 0 }, DEVICE_REST_API: { + CHUNK_SIZE: 512 * 1024, PORT: 8100, PROTOCOL: 'http', TRANSFER_FILES: { BULK: { DIR: '/var/config/rest/bulk', URI: '/mgmt/shared/file-transfer/bulk/' - }, - MADM: { - DIR: '/var/config/rest/madm', - URI: '/mgmt/shared/file-transfer/madm/' } }, USER: 'admin' @@ -186,10 +170,13 @@ describe('Constants', () => { IHEALTH_POLLER: 'ihealthInfo' }, HTTP_REQUEST: { + ALLOWED_PROTOCOLS: ['http', 'https'], DEFAULT_PORT: 80, DEFAULT_PROTOCOL: 'http' }, IHEALTH: { + DEMO_CLEANUP_TIMEOUT: 5 * 60 * 1000, // 5 min. + MAX_HISTORY_LEN: 20, POLLER_CONF: { QKVIEW_COLLECT: { DELAY: 2 * 60 * 1000, // 2 min. @@ -208,10 +195,12 @@ describe('Constants', () => { MAX_PAST_DUE: 2 * 60 * 60 * 1000 // 2 hours } }, + SECRETS_TIMEOUT: 60 * 1000, // 1 min. SERVICE_API: { - LOGIN: 'https://api.f5.com/auth/pub/sso/login/ihealth-api', - UPLOAD: 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews' + LOGIN: 'https://identity.account.f5.com/oauth2/ausp95ykc80HOU7SQ357/v1/token', + UPLOAD: 'https://ihealth2-api.f5.com/qkview-analyzer/api/qkviews' }, + SLEEP_INTERVAL: 120 * 1000, // max sleep interval per iteration for `waiting` state STORAGE_KEY: 'ihealth' }, LOCAL_HOST: 'localhost', @@ -234,6 +223,18 @@ describe('Constants', () => { }, STATS_KEY_SEP: '::', STRICT_TLS_REQUIRED: true, + SYSTEM_POLLER: { + CHUNK_SIZE: 30, + DEMO_CLEANUP_TIMEOUT: 300000, + MAX_HISTORY_LEN: 40, + SECRETS_TIMEOUT: 60000, + SLEEP_INTERVAL: 120000, + WORKERS: 5 + }, + TASK: { + HIGH_PRIORITY: 1, + LOW_PRIORITY: 10 + }, TRACER: { DIR: '/var/tmp/telemetry', ENCODING: 'utf8', @@ -252,6 +253,20 @@ describe('Constants', () => { 6: 'saturday', 7: 'sunday' } - }); + }; + } + + before(() => { + moduleCache.restore(); + }); + + /** + * ATTENTION: + * + * If this test failed it worth to check other tests and source code + * that uses that constant(s) + */ + it('constants verification', () => { + assert.deepStrictEqual(constants, getExpected()); }); }); diff --git a/test/unit/consumers/awsUtilTests.js b/test/unit/consumers/awsUtilTests.js index fbc52488..b21cf7c8 100644 --- a/test/unit/consumers/awsUtilTests.js +++ b/test/unit/consumers/awsUtilTests.js @@ -143,6 +143,7 @@ describe('AWS Util Tests', () => { describe('getMetrics', () => { const testSet = awsUtilTestsData.getMetrics; testSet.tests.forEach((testConf) => testUtil.getCallableIt(testConf)(testConf.name, () => { + clock.restore(); clock = sinon.useFakeTimers(new Date(testSet.timestamp)); const actualMetrics = awsUtil.getMetrics(testConf.input.data, testConf.input.defDimensions); return assert.deepStrictEqual(actualMetrics, testConf.expected); @@ -168,6 +169,7 @@ describe('AWS Util Tests', () => { const testSet = awsUtilTestsData.sendMetrics; testSet.tests.forEach((testConf) => testUtil.getCallableIt(testConf)(testConf.name, () => { + clock.restore(); clock = sinon.useFakeTimers(testSet.timestamp); const context = testUtil.buildConsumerContext({ eventType: 'systemInfo', diff --git a/test/unit/consumers/azureApplicationInsightsConsumerTests.js b/test/unit/consumers/azureApplicationInsightsConsumerTests.js index c6be1006..cc9f69ae 100644 --- a/test/unit/consumers/azureApplicationInsightsConsumerTests.js +++ b/test/unit/consumers/azureApplicationInsightsConsumerTests.js @@ -35,8 +35,8 @@ const requestsUtil = sourceCode('src/lib/utils/requests'); moduleCache.remember(); describe('Azure_Application_Insights', () => { + let aiSpy; let requests; - const aiSpy = sinon.spy(appInsights); before(() => { moduleCache.restore(); @@ -44,6 +44,8 @@ describe('Azure_Application_Insights', () => { beforeEach(() => { requests = []; + aiSpy = sinon.spy(appInsights); + sinon.stub(aiSpy.TelemetryClient.prototype, 'trackMetric').callsFake((metric) => { requests.push(metric); }); @@ -140,6 +142,11 @@ describe('Azure_Application_Insights', () => { { name: 'F5_parent_child4_grandchild1', value: 99 }, { name: 'F5_parent_child4_grandchild2_greatgrandchild1', value: 100 } ]); + + assert( + aiSpy.setup.withArgs('f5-telemetry-default').calledOnce, + 'The app insights default client must be setup once with instrumentation key' + ); }); }); @@ -242,20 +249,29 @@ describe('Azure_Application_Insights', () => { .then(() => { // Second call - simulate logLevel: info context.logger.setLogLevel('info'); + + // configured already + assert( + aiSpy.setup.withArgs('f5-telemetry-default').notCalled, + 'The app insights default client must be setup once with instrumentation key' + ); + return azureAppInsightsIndex(context); }) - .then(() => assert.deepStrictEqual( - logRequests, - [ - { debug: true, warn: true }, - { debug: false, warn: false } - ] - )); - }); + .then(() => { + assert.deepStrictEqual( + logRequests, + [ + { debug: true, warn: true }, + { debug: false, warn: false } + ] + ); - it('should configure default client once', () => assert( - aiSpy.setup.withArgs('f5-telemetry-default').calledOnce, - 'The app insights default client must be setup once with instrumentation key' - )); + assert( + aiSpy.setup.withArgs('f5-telemetry-default').notCalled, + 'The app insights default client must be setup once with instrumentation key' + ); + }); + }); }); }); diff --git a/test/unit/consumers/consumers/Prometheus/index.js b/test/unit/consumers/consumers/Prometheus/index.js new file mode 100644 index 00000000..64e23a96 --- /dev/null +++ b/test/unit/consumers/consumers/Prometheus/index.js @@ -0,0 +1,124 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('assert'); + +const API = require('../../../../../src/lib/consumers/api'); +const hrtimestamp = require('../../../../../src/lib/utils/datetime').hrtimestamp; + +const MODULE_INSTANCES = []; +const TIMESTAMP = hrtimestamp(); + +class PrometheusConsumer extends API.Consumer { + constructor() { + super(); + this.isActive = true; + this.dataCtxs = []; + } + + get allowsPull() { + return true; + } + + get allowsPush() { + return false; + } + + onData(dataCtx) { + assert.ok(this.isActive); + this.dataCtxs.push(dataCtx); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } + + reset() { + this.dataCtxs = []; + } +} + +class PrometheusModule extends API.ConsumerModule { + constructor() { + super(); + this.isActive = true; + this.consumerInstances = []; + this.deletedInstances = []; + } + + createConsumer(config) { + assert.ok(this.isActive); + const inst = new PrometheusConsumer(); + this.consumerInstances.push({ inst, config }); + return inst; + } + + deleteConsumer(instance) { + assert.ok(this.isActive); + const idx = this.consumerInstances.findIndex((val) => val.inst === instance); + if (idx === -1) { + throw new Error('Unknown consumer instance!'); + } + this.consumerInstances.splice(idx, 1); + this.deletedInstances.push(instance); + return super.deleteConsumer(instance); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } +} + +/** + * Load Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + load({ logger, name, path }) { + const inst = new PrometheusModule(); + MODULE_INSTANCES.push({ + inst, logger, name, path + }); + return inst; + }, + getInstances() { + return MODULE_INSTANCES; + }, + getTimestamp() { + return TIMESTAMP; + } +}; diff --git a/test/unit/consumers/consumers/Splunk/index.js b/test/unit/consumers/consumers/Splunk/index.js new file mode 100644 index 00000000..eb24a819 --- /dev/null +++ b/test/unit/consumers/consumers/Splunk/index.js @@ -0,0 +1,165 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-bitwise, no-proto */ + +'use strict'; + +const assert = require('assert'); + +const API = require('../../../../../src/lib/consumers/api'); +const hrtimestamp = require('../../../../../src/lib/utils/datetime').hrtimestamp; + +const MODULE_INSTANCES = []; +const TIMESTAMP = hrtimestamp(); + +if (((global.consumersTests || {}).splunk || {}).fail) { + throw new Error('Expected error on attempt to initialize "Splunk" consumer module'); +} + +class SplunkConsumer extends API.Consumer { + constructor() { + super(); + this.isActive = true; + this.dataCtxs = []; + } + + get allowsPull() { + return false; + } + + get allowsPush() { + return true; + } + + onData(dataCtx) { + assert.ok(this.isActive); + this.dataCtxs.push(dataCtx); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + if (((global.consumersTests || {}).splunk || {}).failConsumerOnUnload) { + throw new Error('Expected error on attempt to unload "Splunk" consumer instance'); + } + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } + + reset() { + this.dataCtxs = []; + } +} + +class SplunkModule extends API.ConsumerModule { + constructor() { + super(); + this.consumerInstances = []; + this.counter = 0; + this.deletedInstances = []; + this.isActive = true; + } + + createConsumer(config) { + assert.ok(this.isActive); + const inst = new SplunkConsumer(); + this.consumerInstances.push({ inst, config }); + + const methodToDelete = ((global.consumersTests || {}).splunk || {}).failConsumerMethodAPI; + if ((this.counter & 0b1) === 0 && methodToDelete) { + inst[methodToDelete] = null; + } + this.counter += 1; + return inst; + } + + deleteConsumer(instance) { + this.counter += 1; + if ((this.counter & 0b1) === 0 && ((global.consumersTests || {}).splunk || {}).failDeleteConsumer) { + throw new Error('Expected error on attempt to delete "Splunk" consumer instance'); + } + + assert.ok(this.isActive); + const idx = this.consumerInstances.findIndex((val) => val.inst === instance); + if (idx === -1) { + throw new Error('Unknown consumer instance!'); + } + this.consumerInstances.splice(idx, 1); + this.deletedInstances.push(instance); + return super.deleteConsumer(instance); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } +} + +/** + * Load Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + load({ logger, name, path }) { + if (((global.consumersTests || {}).splunk || {}).failLoad) { + throw new Error('Expected error splunkModule.load'); + } + if (((global.consumersTests || {}).splunk || {}).failLoadPromise) { + return Promise.reject(new Error('Expected error splunkModule.load.promise')); + } + + const inst = new SplunkModule(); + MODULE_INSTANCES.push({ + inst, logger, name, path + }); + + const methodToDelete = ((global.consumersTests || {}).splunk || {}).failModuleMethodAPI; + if (methodToDelete) { + inst[methodToDelete] = null; + } + + return inst; + }, + getInstances() { + return MODULE_INSTANCES; + }, + getTimestamp() { + return TIMESTAMP; + } +}; + +if (((global.consumersTests || {}).splunk || {}).failExports) { + module.exports = 10; +} +if (((global.consumersTests || {}).splunk || {}).failExportsLoad) { + module.exports = { something: 10 }; +} diff --git a/test/unit/consumers/consumers/default/index.js b/test/unit/consumers/consumers/default/index.js new file mode 100644 index 00000000..c393a26f --- /dev/null +++ b/test/unit/consumers/consumers/default/index.js @@ -0,0 +1,44 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Consumer API v1 + +const hrtimestamp = require('../../../../../src/lib/utils/datetime').hrtimestamp; + +let DATA_CTXS = []; +const TIMESTAMP = hrtimestamp(); + +if (((global.consumersTests || {}).default || {}).fail) { + throw new Error('Expected error on attempt to initialize "default" consumer module'); +} + +module.exports = function defaultConsumer(dataCtx) { + DATA_CTXS.push(dataCtx); +}; + +module.exports.reset = function () { + DATA_CTXS = []; +}; + +module.exports.getData = function () { + return DATA_CTXS; +}; + +module.exports.getTimestamp = function () { + return TIMESTAMP; +}; diff --git a/test/unit/consumers/consumers/not-a-consumer/Splunk/index.js/index.js b/test/unit/consumers/consumers/not-a-consumer/Splunk/index.js/index.js new file mode 100644 index 00000000..c7aba7e9 --- /dev/null +++ b/test/unit/consumers/consumers/not-a-consumer/Splunk/index.js/index.js @@ -0,0 +1,19 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +module.exports = () => {}; diff --git a/test/unit/consumers/consumersTests.js b/test/unit/consumers/consumersTests.js new file mode 100644 index 00000000..9b3c5043 --- /dev/null +++ b/test/unit/consumers/consumersTests.js @@ -0,0 +1,1539 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable global-require, import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const path = require('path'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtils = require('../shared/util'); + +const ConsumersService = sourceCode('src/lib/consumers'); +const tracerManager = sourceCode('src/lib/tracerManager'); + +moduleCache.remember(); + +describe('Consumers', () => { + let appEvents; + let configWorker; + let consumers; + let consumersStats; + let coreStub; + let loadedConsumers; + + const complexKeys = [ + 'config', 'consumer', 'logger', 'tracer' + ]; + + function filterConsumerCtx(consumerCtx) { + const ret = { + complex: {}, + other: {} + }; + Object.keys(consumerCtx).forEach((key) => { + ret[complexKeys.includes(key) ? 'complex' : 'other'][key] = consumerCtx[key]; + }); + return ret; + } + + function processDeclaration(declaration) { + return Promise.all([ + appEvents.waitFor('consumers.config.done'), + configWorker.processDeclaration(declaration) + ]); + } + + function processNamespaceDeclaration(declaration, namespace) { + return Promise.all([ + appEvents.waitFor('consumers.config.done'), + configWorker.processNamespaceDeclaration(declaration, namespace) + ]); + } + + function verifyComplexProps(consumerCtx) { + assert.isObject(consumerCtx.complex.config); + assert.isFunction(consumerCtx.complex.consumer); + assert.isObject(consumerCtx.complex.logger); + assert.isFunction(consumerCtx.complex.logger.info); + + if (consumerCtx.other.v2) { + assert.isFunction(consumerCtx.complex.tracer); + } else if (consumerCtx.complex.tracer) { + assert.isObject(consumerCtx.complex.tracer); + } else { + assert.isNull(consumerCtx.complex.tracer); + } + + assert.deepStrictEqual( + Object.keys(consumerCtx.complex).filter((k) => !complexKeys.includes(k)), + [] + ); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + // used in ./consumers to test modules + delete global.consumersTests; + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + coreStub.utilMisc.generateUuid.numbersOnly = false; + + consumersStats = {}; + loadedConsumers = []; + + appEvents.on('consumers.change', (getConsumers) => { + loadedConsumers = getConsumers(); + loadedConsumers.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase())); + }); + appEvents.on('consumers.config.done', (stats) => { + consumersStats = stats; + }); + + await coreStub.startServices(); + coreStub.logger.removeAllMessages(); + }); + + afterEach(async () => { + delete global.consumersTests; + await coreStub.destroyServices(); + sinon.restore(); + }); + + describe('service initialization', () => { + it('should read plugins from default directory', async () => { + const cs = new ConsumersService(); + cs.initialize(appEvents); + assert.isTrue(cs.pluginsDir.endsWith('src/lib/consumers')); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isFalse(cs.isRunning()); + assert.isTrue(cs.isStopped()); + + await cs.start(); + + assert.deepStrictEqual(cs.numberOfConsumers, 0); + assert.deepStrictEqual(cs.numberOfModules, 0); + assert.deepStrictEqual(cs.supportedModules, [ + 'AWS_CloudWatch', + 'AWS_S3', + 'Azure_Application_Insights', + 'Azure_Log_Analytics', + 'DataDog', + 'default', + 'ElasticSearch', + 'F5_Cloud', + 'Generic_HTTP', + 'Google_Cloud_Logging', + 'Google_Cloud_Monitoring', + 'Graphite', + 'Kafka', + 'OpenTelemetry_Exporter', + 'Prometheus', + 'Splunk', + 'Statsd', + 'Sumo_Logic' + ]); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isTrue(cs.isRunning()); + assert.isFalse(cs.isStopped()); + + await cs.destroy(); + }); + + it('should fail to start when unable to read directory with plug-ins', async () => { + const cs = new ConsumersService('/telemetry-consumers'); + cs.initialize(appEvents); + assert.deepStrictEqual(cs.pluginsDir, '/telemetry-consumers'); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isFalse(cs.isRunning()); + assert.isTrue(cs.isStopped()); + + await assert.isRejected(cs.start(), /no such file or directory/); + }); + + it('should start service and make a list of supported plug-ins', async () => { + const cdir = path.join(__dirname, 'consumers'); + const cs = new ConsumersService(cdir); + cs.initialize(appEvents); + assert.deepStrictEqual(cs.pluginsDir, cdir); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isFalse(cs.isRunning()); + assert.isTrue(cs.isStopped()); + + await cs.start(); + + assert.deepStrictEqual(cs.pluginsDir, cdir); + assert.deepStrictEqual(cs.numberOfConsumers, 0); + assert.deepStrictEqual(cs.numberOfModules, 0); + assert.deepStrictEqual(cs.supportedModules, [ + 'default', + 'Prometheus', + 'Splunk' + ]); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isTrue(cs.isRunning()); + assert.isFalse(cs.isStopped()); + + await cs.destroy(); + }); + + it('should start service even when no consumers in the directory', async () => { + const cdir = path.join(__dirname, 'consumers', 'not-a-consumer'); + const cs = new ConsumersService(cdir); + cs.initialize(appEvents); + assert.deepStrictEqual(cs.pluginsDir, cdir); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isFalse(cs.isRunning()); + assert.isTrue(cs.isStopped()); + + await cs.start(); + + assert.deepStrictEqual(cs.pluginsDir, cdir); + assert.deepStrictEqual(cs.numberOfConsumers, 0); + assert.deepStrictEqual(cs.numberOfModules, 0); + assert.deepStrictEqual(cs.supportedModules, []); + assert.isFalse(cs.isDestroyed()); + assert.isFalse(cs.isRestarting()); + assert.isTrue(cs.isRunning()); + assert.isFalse(cs.isStopped()); + + await cs.destroy(); + }); + }); + + describe('service start/stop/destroy/events handling', () => { + beforeEach(() => { + consumers = new ConsumersService(path.join(__dirname, 'consumers')); + consumers.initialize(appEvents); + return consumers.start() + .then(() => { + assert.isFalse(consumers.isDestroyed()); + assert.isFalse(consumers.isRestarting()); + assert.isTrue(consumers.isRunning()); + assert.isFalse(consumers.isStopped()); + + return processDeclaration({ class: 'Telemetry' }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumers.supportedModules, [ + 'default', + 'Prometheus', + 'Splunk' + ]); + assert.deepStrictEqual(loadedConsumers, []); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + }); + }); + + afterEach(() => Promise.all([ + consumers.isRunning() ? appEvents.waitFor('consumers.change') : Promise.resolve(), + consumers.isRunning() ? appEvents.waitFor('*.config.done') : Promise.resolve(), + configWorker.processDeclaration({ class: 'Telemetry' }) + ]) + .then(() => { + assert.deepStrictEqual(loadedConsumers, []); + return consumers.stop(); + }) + .then(() => consumers.destroy())); + + it('should log error when unable to load consumer (missing directory)', () => processDeclaration({ + class: 'Telemetry', + genericHttpConsmer: dummies.declaration.consumer.genericHttp.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to load consumer plug-in "Generic_HTTP": unknown type/ + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::genericHttpConsmer".*plug-in "Generic_HTTP" does not exist/ + ); + })); + + it('should log error when unable to load consumer (unexpected module init exception, API v1)', () => { + global.consumersTests = { + default: { + fail: true + } + }; + return processDeclaration({ + class: 'Telemetry', + defaultConsumer: dummies.declaration.consumer.default.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error on attempt to initialize "default" consumer module/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to load consumer plug-in "default"/ + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::defaultConsumer".*plug-in "default" not loaded/ + ); + }); + }); + + it('should log error when unable to load consumer (unexpected module.load() exception, API v2)', () => { + global.consumersTests = { + splunk: { + failLoad: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Uncaught exception on attempt to load consumer plug-in "Splunk"/ + ); + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error splunkModule.load/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + }); + }); + + it('should log error when unable to load consumer (unexpected module.load() exception, API v2, promise)', () => { + global.consumersTests = { + splunk: { + failLoadPromise: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Uncaught exception on attempt to load consumer plug-in "Splunk"/ + ); + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error splunkModule.load.promise/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + }); + }); + + it('should log error when unable to load consumer (unexpected module init exception, API v2)', () => { + global.consumersTests = { + splunk: { + fail: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error on attempt to initialize "Splunk" consumer module/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to load consumer plug-in "Splunk"/ + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + }); + }); + + it('should log error when unable to load consumer (unexpected module.exports type), API v2)', () => { + global.consumersTests = { + splunk: { + failExports: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Consumer plug-in "Splunk" should export function \(API v1\)/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + }); + }); + + it('should log error when unable to load consumer (missing .load() function), API v2)', () => { + global.consumersTests = { + splunk: { + failExportsLoad: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Consumer plug-in "Splunk" should export function \(API v1\)/gm + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + }); + }); + + [ + 'createConsumer', + 'deleteConsumer', + 'onLoad', + 'onUnload' + ].forEach((methodName) => it(`should log error when unable to load consumer (module instance has no required method "${methodName}", API v2)`, () => { + global.consumersTests = { + splunk: { + failModuleMethodAPI: methodName + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + /Uncaught exception on attempt to load consumer plug-in "Splunk"/ + ); + assert.includeMatch( + coreStub.logger.messages.error, + new RegExp(`Consumer plug-in "Splunk" has no required method "${methodName}"`, 'gm'), + `should log error message that "${methodName}" method is missing` + ); + assert.includeMatch( + coreStub.logger.messages.verbose, + /Module.*consumers\/Splunk.*was unloaded/ + ); + assert.includeMatch( + coreStub.logger.messages.warning, + /Unable to initialize consumer "f5telemetry_default::splunkConsumer".*plug-in "Splunk" not loaded/ + ); + coreStub.logger.removeAllMessages(); + }); + })); + + [ + 'onData', + 'onLoad', + 'onUnload' + ].forEach((methodName) => it(`should log error when unable to load consumer (consumer instance has no required method "${methodName}", API v2)`, () => { + global.consumersTests = { + splunk: { + failConsumerMethodAPI: methodName + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + + assert.includeMatch( + coreStub.logger.messages.error, + new RegExp(`Consumer plug-in instance "Splunk" has no required method "${methodName}"`, 'gm'), + `should log error message that "${methodName}" method is missing` + ); + assert.notIncludeMatch( + coreStub.logger.messages.verbose, + /Module.*consumers\/Splunk.*was unloaded/ + ); + coreStub.logger.removeAllMessages(); + }); + })); + + it('should log error when unable to unload consumer (onUnload() error), API v2)', () => { + global.consumersTests = { + splunk: { + failConsumerOnUnload: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error on attempt to unload "Splunk" consumer instance/gm + ); + assert.includeMatch( + coreStub.logger.messages.error, + /Uncaught exception on attemp to call ".onUnload\(\)" method for consumer "f5telemetry_default::splunkConsumer"/ + ); + }); + }); + + it('should log error when unable to unload consumer (deleteConsumer() error), API v2)', () => { + global.consumersTests = { + splunk: { + failDeleteConsumer: true + } + }; + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.includeMatch( + coreStub.logger.messages.error, + /Expected error on attempt to delete "Splunk" consumer instance/gm + ); + assert.includeMatch( + coreStub.logger.messages.error, + /Uncaught exception on attemp to call ".deleteConsumer\(\)" method for consumer "f5telemetry_default::splunkConsumer/gm + ); + }); + }); + + it('should process declaration (API v1 only)', () => { + let dcModuleTS = null; + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + + assert.lengthOf(loadedConsumers, 1); + const dcInstCtx = filterConsumerCtx(loadedConsumers[0]); + + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer1', + id: 'f5telemetry_default::defaultConsumer1', + metadata: null, + name: 'defaultConsumer1', + type: 'default', + v2: false + }); + + dcInstCtx.complex.consumer('something'); + assert.deepStrictEqual(dcInstCtx.complex.consumer.getData(), ['something']); + dcInstCtx.complex.consumer.reset(); + assert.deepStrictEqual(dcInstCtx.complex.consumer.getData(), []); + + dcModuleTS = dcInstCtx.complex.consumer.getTimestamp(); + + assert.lengthOf(tracerManager.registered(), 0); + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer3: dummies.declaration.consumer.default.decrypted({ + trace: true + }), + defaultConsumer4: dummies.declaration.consumer.default.decrypted({ + enable: false + }) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 3); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 3, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 3); + assert.lengthOf(tracerManager.registered(), 1); + + for (let i = 1; i < loadedConsumers.length + 1; i += 1) { + const dcInstCtx = filterConsumerCtx(loadedConsumers[i - 1]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: `f5telemetry_default::defaultConsumer${i}`, + id: `f5telemetry_default::defaultConsumer${i}`, + metadata: null, + name: `defaultConsumer${i}`, + type: 'default', + v2: false + }); + assert.deepStrictEqual(dcModuleTS, dcInstCtx.complex.consumer.getTimestamp()); + dcInstCtx.complex.consumer(`something-${i}`); + } + + assert.deepStrictEqual(loadedConsumers[0].consumer.getData(), [ + 'something-1', + 'something-2', + 'something-3' + ]); + loadedConsumers[0].consumer.reset(); + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer3: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer4: dummies.declaration.consumer.default.decrypted({}) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 2); + assert.lengthOf(tracerManager.registered(), 0); + + for (let i = 0; i < loadedConsumers.length; i += 1) { + const dcInstCtx = filterConsumerCtx(loadedConsumers[i]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: `f5telemetry_default::defaultConsumer${i + 3}`, + id: `f5telemetry_default::defaultConsumer${i + 3}`, + metadata: null, + name: `defaultConsumer${i + 3}`, + type: 'default', + v2: false + }); + assert.deepStrictEqual(dcModuleTS, dcInstCtx.complex.consumer.getTimestamp()); + dcInstCtx.complex.consumer(`something-${i}`); + } + + assert.deepStrictEqual(loadedConsumers[0].consumer.getData(), [ + 'something-0', + 'something-1' + ]); + loadedConsumers[0].consumer.reset(); + + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.lengthOf(loadedConsumers, 0); + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer: dummies.declaration.consumer.default.decrypted({}) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 1); + const dcInstCtx = filterConsumerCtx(loadedConsumers[0]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer', + id: 'f5telemetry_default::defaultConsumer', + metadata: null, + name: 'defaultConsumer', + type: 'default', + v2: false + }); + + dcInstCtx.complex.consumer('something'); + assert.deepStrictEqual(dcInstCtx.complex.consumer.getData(), ['something']); + dcInstCtx.complex.consumer.reset(); + assert.deepStrictEqual(dcInstCtx.complex.consumer.getData(), []); + + assert.isAbove(dcInstCtx.complex.consumer.getTimestamp(), dcModuleTS); + }); + }); + + it('should process declaration (API v2 only)', () => { + let splunkModuleTS = null; + let splunkModuleCtx = null; + let splunkConsumerCtx = null; + + return processDeclaration({ + class: 'Telemetry', + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + + assert.lengthOf(loadedConsumers, 1); + const dcInstCtx = filterConsumerCtx(loadedConsumers[0]); + + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::splunkConsumer1', + id: 'f5telemetry_default::splunkConsumer1', + metadata: null, + name: 'splunkConsumer1', + type: 'Splunk', + v2: true + }); + + const splunk = require('./consumers/Splunk'); + + assert.lengthOf(splunk.getInstances(), 1); + splunkModuleCtx = splunk.getInstances()[0]; + splunkModuleTS = splunk.getTimestamp(); + + assert.deepStrictEqual(splunkModuleCtx.name, 'Splunk'); + assert.isTrue(splunkModuleCtx.path.endsWith('consumers/Splunk')); + assert.isObject(splunkModuleCtx.logger); + assert.isFunction(splunkModuleCtx.logger.info); + assert.lengthOf(splunkModuleCtx.inst.consumerInstances, 1); + assert.lengthOf(splunkModuleCtx.inst.deletedInstances, 0); + + splunkConsumerCtx = splunkModuleCtx.inst.consumerInstances[0]; + dcInstCtx.complex.consumer('something'); + assert.deepStrictEqual(splunkConsumerCtx.inst.dataCtxs, ['something']); + splunkConsumerCtx.inst.reset(); + + assert.lengthOf(tracerManager.registered(), 0); + + return processDeclaration({ + class: 'Telemetry', + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer3: dummies.declaration.consumer.splunk.minimal.decrypted({ + trace: true + }), + splunkConsumer4: dummies.declaration.consumer.splunk.minimal.decrypted({ + enable: false + }) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 3); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 3, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 3); + assert.lengthOf(tracerManager.registered(), 1); + + for (let i = 1; i < loadedConsumers.length + 1; i += 1) { + const dcInstCtx = filterConsumerCtx(loadedConsumers[i - 1]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: `f5telemetry_default::splunkConsumer${i}`, + id: `f5telemetry_default::splunkConsumer${i}`, + metadata: null, + name: `splunkConsumer${i}`, + type: 'Splunk', + v2: true + }); + dcInstCtx.complex.consumer(`something-${i}`); + } + + const splunk = require('./consumers/Splunk'); + + assert.lengthOf(splunk.getInstances(), 1); + assert.isTrue(splunk.getInstances()[0] === splunkModuleCtx); + assert.deepStrictEqual(splunk.getTimestamp(), splunkModuleTS); + + assert.lengthOf(splunkModuleCtx.inst.consumerInstances, 3); + assert.lengthOf(splunkModuleCtx.inst.deletedInstances, 1); + + assert.isFalse(splunkModuleCtx.inst.deletedInstances[0].isActive); + assert.isTrue(splunkModuleCtx.inst.deletedInstances[0] === splunkConsumerCtx.inst); + + const reloadedConsumer = splunkModuleCtx.inst.consumerInstances.find( + (o) => o.inst.id === splunkConsumerCtx.inst.id + ); + assert.isDefined(reloadedConsumer); + assert.isFalse(reloadedConsumer.inst === splunkConsumerCtx.inst); + + assert.sameDeepMembers( + splunkModuleCtx.inst.consumerInstances.map((ci) => ci.inst.dataCtxs), + [ + ['something-1'], + ['something-2'], + ['something-3'] + ] + ); + splunkModuleCtx.inst.consumerInstances.forEach((ci) => ci.inst.reset()); + + return processDeclaration({ + class: 'Telemetry', + splunkConsumer3: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer4: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 2); + assert.lengthOf(tracerManager.registered(), 0); + + for (let i = 0; i < loadedConsumers.length; i += 1) { + const dcInstCtx = filterConsumerCtx(loadedConsumers[i]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: `f5telemetry_default::splunkConsumer${i + 3}`, + id: `f5telemetry_default::splunkConsumer${i + 3}`, + metadata: null, + name: `splunkConsumer${i + 3}`, + type: 'Splunk', + v2: true + }); + dcInstCtx.complex.consumer(`something-${i}`); + } + + const splunk = require('./consumers/Splunk'); + assert.lengthOf(splunk.getInstances(), 1); + assert.isTrue(splunk.getInstances()[0] === splunkModuleCtx); + assert.deepStrictEqual(splunk.getTimestamp(), splunkModuleTS); + + assert.lengthOf(splunkModuleCtx.inst.consumerInstances, 2); + assert.lengthOf(splunkModuleCtx.inst.deletedInstances, 4); + + const rets = splunkModuleCtx.inst.deletedInstances.map((inst) => { + assert.isFalse(inst.isActive); + const reloadedConsumer = splunkModuleCtx.inst.consumerInstances.find( + (ci) => ci.inst.id === inst.id + ); + if (reloadedConsumer) { + assert.isFalse(reloadedConsumer.inst === inst); + return true; + } + return false; + }); + assert.sameDeepMembers(rets, [true, false, false, false], 'should reload 1 consumer'); + + assert.sameDeepMembers( + splunkModuleCtx.inst.consumerInstances.map((ci) => ci.inst.dataCtxs), + [ + ['something-0'], + ['something-1'] + ] + ); + + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.lengthOf(loadedConsumers, 0); + + return processDeclaration({ + class: 'Telemetry', + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 1); + + const dcInstCtx = filterConsumerCtx(loadedConsumers[0]); + verifyComplexProps(dcInstCtx); + assert.deepStrictEqual(dcInstCtx.other, { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::splunkConsumer', + id: 'f5telemetry_default::splunkConsumer', + metadata: null, + name: 'splunkConsumer', + type: 'Splunk', + v2: true + }); + + const splunk = require('./consumers/Splunk'); + + assert.lengthOf(splunk.getInstances(), 1); + assert.isFalse(splunkModuleCtx === splunk.getInstances()[0]); + splunkModuleCtx = splunk.getInstances()[0]; + + assert.deepStrictEqual(splunkModuleCtx.name, 'Splunk'); + assert.isTrue(splunkModuleCtx.path.endsWith('consumers/Splunk')); + assert.isObject(splunkModuleCtx.logger); + assert.isFunction(splunkModuleCtx.logger.info); + assert.lengthOf(splunkModuleCtx.inst.consumerInstances, 1); + assert.lengthOf(splunkModuleCtx.inst.deletedInstances, 0); + + splunkConsumerCtx = splunkModuleCtx.inst.consumerInstances[0]; + dcInstCtx.complex.consumer('something'); + assert.deepStrictEqual(splunkConsumerCtx.inst.dataCtxs, ['something']); + + assert.isAbove(splunk.getTimestamp(), splunkModuleTS); + }); + }); + + it('should destroy instance and unsubscribe from events', () => processDeclaration({ + class: 'Telemetry', + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 1); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 1, + modules: 1 + }); + return consumers.destroy(); + }) + .then(() => { + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + return Promise.all([ + testUtils.sleep(50), + configWorker.processDeclaration({ + class: 'Telemetry', + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + ]); + }) + .then(() => { + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + })); + + it('should load/unload consumers from affected namespace only', () => { + const customNs1 = {}; + const customNs2 = {}; + const defaultNs = {}; + const namespace1 = 'NewNamespace'; + const namespace2 = 'NewNamespace-2'; + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 2); + + loadedConsumers + .map(filterConsumerCtx) + .forEach((lc) => { + defaultNs[lc.other.id] = lc.complex.consumer; + }); + + assert.lengthOf(Object.keys(defaultNs), 2); + + return processNamespaceDeclaration({ + class: 'Telemetry_Namespace', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({}), + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }, namespace1); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 6); + assert.deepStrictEqual(consumers.numberOfModules, 2); + assert.deepStrictEqual(consumersStats, { + consumers: 6, + modules: 2 + }); + assert.lengthOf(loadedConsumers, 6); + + const defaultNs2 = {}; + loadedConsumers + .map(filterConsumerCtx) + .forEach((lc) => { + (lc.other.id.includes(namespace1) + ? customNs1 + : defaultNs2 + )[lc.other.id] = lc.complex.consumer; + }); + + assert.lengthOf(Object.keys(defaultNs2), 2); + assert.lengthOf(Object.keys(customNs1), 4); + + assert.sameDeepMembers( + Object.keys(defaultNs), + Object.keys(defaultNs2) + ); + Object.keys(defaultNs) + .forEach((key) => { + assert.isTrue(defaultNs[key] === defaultNs2[key], 'should not reload consumers from default namespace'); + }); + + return processNamespaceDeclaration({ + class: 'Telemetry_Namespace', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({}), + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }, namespace2); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 10); + assert.deepStrictEqual(consumers.numberOfModules, 2); + assert.deepStrictEqual(consumersStats, { + consumers: 10, + modules: 2 + }); + assert.lengthOf(loadedConsumers, 10); + + const defaultNs2 = {}; + const customNs12 = {}; + + loadedConsumers + .map(filterConsumerCtx) + .forEach((lc) => { + let s = defaultNs2; + if (lc.other.id.includes(namespace2)) { + s = customNs2; + } else if (lc.other.id.includes(namespace1)) { + s = customNs12; + } + s[lc.other.id] = lc.complex.consumer; + }); + + assert.lengthOf(Object.keys(defaultNs2), 2); + assert.lengthOf(Object.keys(customNs12), 4); + assert.lengthOf(Object.keys(customNs2), 4); + + assert.sameDeepMembers( + Object.keys(defaultNs), + Object.keys(defaultNs2) + ); + assert.sameDeepMembers( + Object.keys(customNs1), + Object.keys(customNs12) + ); + Object.keys(defaultNs) + .forEach((key) => { + assert.isTrue(defaultNs[key] === defaultNs2[key], 'should not reload consumers from default namespace'); + }); + Object.keys(customNs1) + .forEach((key) => { + assert.isTrue(customNs1[key] === customNs12[key], 'should not reload consumers from namespace1'); + }); + + return processNamespaceDeclaration({ + class: 'Telemetry_Namespace' + }, namespace1); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 6); + assert.deepStrictEqual(consumers.numberOfModules, 2); + assert.deepStrictEqual(consumersStats, { + consumers: 6, + modules: 2 + }); + assert.lengthOf(loadedConsumers, 6); + + const defaultNs2 = {}; + const customNs12 = {}; + const customNs22 = {}; + + loadedConsumers + .map(filterConsumerCtx) + .forEach((lc) => { + let s = defaultNs2; + if (lc.other.id.includes(namespace2)) { + s = customNs22; + } else if (lc.other.id.includes(namespace1)) { + s = customNs12; + } + s[lc.other.id] = lc.complex.consumer; + }); + + assert.lengthOf(Object.keys(defaultNs2), 2); + assert.lengthOf(Object.keys(customNs12), 0); + assert.lengthOf(Object.keys(customNs22), 4); + + assert.sameDeepMembers( + Object.keys(defaultNs), + Object.keys(defaultNs2) + ); + assert.sameDeepMembers( + Object.keys(customNs2), + Object.keys(customNs22) + ); + Object.keys(defaultNs) + .forEach((key) => { + assert.isTrue(defaultNs[key] === defaultNs2[key], 'should not reload consumers from default namespace'); + }); + Object.keys(customNs1) + .forEach((key) => { + assert.isTrue(customNs2[key] === customNs22[key], 'should not reload consumers from namespace2'); + }); + + return processNamespaceDeclaration({ + class: 'Telemetry_Namespace' + }, namespace2); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + assert.lengthOf(loadedConsumers, 2); + + const defaultNs2 = {}; + const customNs12 = {}; + const customNs22 = {}; + + loadedConsumers + .map(filterConsumerCtx) + .forEach((lc) => { + let s = defaultNs2; + if (lc.other.id.includes(namespace2)) { + s = customNs22; + } else if (lc.other.id.includes(namespace1)) { + s = customNs12; + } + s[lc.other.id] = lc.complex.consumer; + }); + + assert.lengthOf(Object.keys(defaultNs2), 2); + assert.lengthOf(Object.keys(customNs12), 0); + assert.lengthOf(Object.keys(customNs22), 0); + + assert.sameDeepMembers( + Object.keys(defaultNs), + Object.keys(defaultNs2) + ); + Object.keys(defaultNs) + .forEach((key) => { + assert.isTrue(defaultNs[key] === defaultNs2[key], 'should not reload consumers from default namespace'); + }); + + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.lengthOf(loadedConsumers, 0); + }); + }); + + it('should load pull consumers', () => processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.pullConsumer.default.decrypted({ + systemPoller: 'poller' + }), + poller: dummies.declaration.systemPoller.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 2); + assert.deepStrictEqual(consumers.numberOfModules, 1); + assert.deepStrictEqual(consumersStats, { + consumers: 2, + modules: 1 + }); + + assert.lengthOf(loadedConsumers, 2); + const others = []; + loadedConsumers.forEach((lc) => { + lc = filterConsumerCtx(lc); + verifyComplexProps(lc); + others.push(lc.other); + }); + + assert.sameDeepMembers(others, [ + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer1', + id: 'f5telemetry_default::defaultConsumer1', + metadata: null, + name: 'defaultConsumer1', + type: 'default', + v2: false + }, + { + allowsPull: true, + allowsPush: false, + class: 'Telemetry_Pull_Consumer', + fullName: 'f5telemetry_default::defaultConsumer2', + id: 'f5telemetry_default::defaultConsumer2', + metadata: null, + name: 'defaultConsumer2', + type: 'default', + v2: false + } + ]); + + return processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.pullConsumer.default.decrypted({ + systemPoller: 'poller' + }), + poller: dummies.declaration.systemPoller.minimal.decrypted({}), + prometheus: dummies.declaration.pullConsumer.prometheus.decrypted({ + systemPoller: 'poller' + }) + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 3); + assert.deepStrictEqual(consumers.numberOfModules, 2); + assert.deepStrictEqual(consumersStats, { + consumers: 3, + modules: 2 + }); + + assert.lengthOf(loadedConsumers, 3); + const others = []; + loadedConsumers.forEach((lc) => { + lc = filterConsumerCtx(lc); + verifyComplexProps(lc); + others.push(lc.other); + }); + + assert.sameDeepMembers(others, [ + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer1', + id: 'f5telemetry_default::defaultConsumer1', + metadata: null, + name: 'defaultConsumer1', + type: 'default', + v2: false + }, + { + allowsPull: true, + allowsPush: false, + class: 'Telemetry_Pull_Consumer', + fullName: 'f5telemetry_default::defaultConsumer2', + id: 'f5telemetry_default::defaultConsumer2', + metadata: null, + name: 'defaultConsumer2', + type: 'default', + v2: false + }, + { + allowsPull: true, + allowsPush: false, + class: 'Telemetry_Pull_Consumer', + fullName: 'f5telemetry_default::prometheus', + id: 'f5telemetry_default::prometheus', + metadata: null, + name: 'prometheus', + type: 'Prometheus', + v2: true + } + ]); + + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + + assert.lengthOf(loadedConsumers, 0); + })); + + it('should process declaration (mixed)', () => processDeclaration({ + class: 'Telemetry', + defaultConsumer1: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({}), + splunkConsumer1: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}) + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 4); + assert.deepStrictEqual(consumers.numberOfModules, 2); + assert.deepStrictEqual(consumersStats, { + consumers: 4, + modules: 2 + }); + + assert.lengthOf(loadedConsumers, 4); + + const others = []; + loadedConsumers.forEach((lc) => { + lc = filterConsumerCtx(lc); + verifyComplexProps(lc); + others.push(lc.other); + }); + + assert.sameDeepMembers(others, [ + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::splunkConsumer1', + id: 'f5telemetry_default::splunkConsumer1', + metadata: null, + name: 'splunkConsumer1', + type: 'Splunk', + v2: true + }, + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::splunkConsumer2', + id: 'f5telemetry_default::splunkConsumer2', + metadata: null, + name: 'splunkConsumer2', + type: 'Splunk', + v2: true + }, + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer1', + id: 'f5telemetry_default::defaultConsumer1', + metadata: null, + name: 'defaultConsumer1', + type: 'default', + v2: false + }, + { + allowsPull: false, + allowsPush: true, + class: 'Telemetry_Consumer', + fullName: 'f5telemetry_default::defaultConsumer2', + id: 'f5telemetry_default::defaultConsumer2', + metadata: null, + name: 'defaultConsumer2', + type: 'default', + v2: false + } + ]); + return processDeclaration({ + class: 'Telemetry' + }); + }) + .then(() => { + assert.deepStrictEqual(consumers.numberOfConsumers, 0); + assert.deepStrictEqual(consumers.numberOfModules, 0); + assert.deepStrictEqual(consumersStats, { + consumers: 0, + modules: 0 + }); + assert.lengthOf(loadedConsumers, 0); + assert.lengthOf(tracerManager.registered(), 0); + })); + }); +}); diff --git a/test/unit/consumers/dataDogConsumerTests.js b/test/unit/consumers/dataDogConsumerTests.js index 4c4e0124..36b3e8e9 100644 --- a/test/unit/consumers/dataDogConsumerTests.js +++ b/test/unit/consumers/dataDogConsumerTests.js @@ -19,7 +19,6 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); -const nock = require('nock'); const sinon = require('sinon'); const zlib = require('zlib'); @@ -30,7 +29,7 @@ const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); const dataDogIndex = sourceCode('src/lib/consumers/DataDog/index'); -const httpUtil = sourceCode('src/lib/consumers/shared/httpUtil'); +const httpUtil = sourceCode('src/lib/utils/http'); moduleCache.remember(); @@ -219,7 +218,7 @@ describe('DataDog', () => { }); afterEach(() => { - nock.cleanAll(); + testUtil.nockCleanup(); sinon.restore(); }); diff --git a/test/unit/consumers/defaultConsumerTests.js b/test/unit/consumers/defaultConsumerTests.js index fb96d185..9da92343 100644 --- a/test/unit/consumers/defaultConsumerTests.js +++ b/test/unit/consumers/defaultConsumerTests.js @@ -29,7 +29,7 @@ const defaultConsumer = sourceCode('src/lib/consumers/default'); moduleCache.remember(); -describe('Default Consumer', () => { +describe.skip('Default Consumer', () => { const context = { event: {}, config: {}, diff --git a/test/unit/consumers/elasticSearchConsumerTests.js b/test/unit/consumers/elasticSearchConsumerTests.js index f16bb926..a7d2a352 100644 --- a/test/unit/consumers/elasticSearchConsumerTests.js +++ b/test/unit/consumers/elasticSearchConsumerTests.js @@ -26,7 +26,7 @@ const sourceCode = require('../shared/sourceCode'); const testUtil = require('../shared/util'); const elasticSearchIndex = sourceCode('src/lib/consumers/ElasticSearch/index'); -const httpUtil = sourceCode('src/lib/consumers/shared/httpUtil'); +const httpUtil = sourceCode('src/lib/utils/http'); const util = sourceCode('src/lib/utils/misc'); moduleCache.remember(); diff --git a/test/unit/consumers/f5CloudConsumerTests.js b/test/unit/consumers/f5CloudConsumerTests.js index 907df746..bb031694 100644 --- a/test/unit/consumers/f5CloudConsumerTests.js +++ b/test/unit/consumers/f5CloudConsumerTests.js @@ -48,7 +48,7 @@ if (IS_8_11_1_PLUS) { moduleCache.remember(); -(IS_8_11_1_PLUS ? describe : describe.skip)('F5_Cloud', () => { +(IS_8_11_1_PLUS ? describe.skip : describe.skip)('F5_Cloud', () => { if (!IS_8_11_1_PLUS) { return; } diff --git a/test/unit/consumers/gcpUtilTests.js b/test/unit/consumers/gcpUtilTests.js index eb252a13..73930ddf 100644 --- a/test/unit/consumers/gcpUtilTests.js +++ b/test/unit/consumers/gcpUtilTests.js @@ -25,6 +25,7 @@ const sinon = require('sinon'); const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); const gcpUtil = sourceCode('src/lib/consumers/shared/gcpUtil'); @@ -48,11 +49,12 @@ describe('Google Cloud Util Tests', () => { afterEach(() => { jwtSignStub.restore(); + testUtil.nockCleanup(); }); it('should get an Access Token from a signed JWT', () => { - nock('https://oauth2.googleapis.com/token') - .post('') + nock('https://oauth2.googleapis.com') + .post('/token') .reply(200, (_, body) => { assert.isTrue(/&assertion=somejsonwebtoken/.test(body)); return accessTokenResponse; @@ -70,8 +72,8 @@ describe('Google Cloud Util Tests', () => { }); it('should cache multiple tokens', () => { - nock('https://oauth2.googleapis.com/token') - .post('') + nock('https://oauth2.googleapis.com') + .post('/token') .times(2) .reply(200, accessTokenResponse); diff --git a/test/unit/consumers/genericHTTPConsumerTests.js b/test/unit/consumers/genericHTTPConsumerTests.js index a66f7d98..74eb84f6 100644 --- a/test/unit/consumers/genericHTTPConsumerTests.js +++ b/test/unit/consumers/genericHTTPConsumerTests.js @@ -28,7 +28,7 @@ const sourceCode = require('../shared/sourceCode'); const testUtil = require('../shared/util'); const genericHttpIndex = sourceCode('src/lib/consumers/Generic_HTTP/index'); -const httpUtil = sourceCode('src/lib/consumers/shared/httpUtil'); +const httpUtil = sourceCode('src/lib/utils/http'); moduleCache.remember(); @@ -46,9 +46,9 @@ describe('Generic_HTTP', () => { }); afterEach(() => { - testUtil.checkNockActiveMocks(nock); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); - nock.cleanAll(); }); describe('process', () => { diff --git a/test/unit/consumers/googleCloudLoggingTests.js b/test/unit/consumers/googleCloudLoggingTests.js index b3ca9fde..bec1dabf 100644 --- a/test/unit/consumers/googleCloudLoggingTests.js +++ b/test/unit/consumers/googleCloudLoggingTests.js @@ -83,8 +83,8 @@ describe('Google_Cloud_Logging', () => { }); afterEach(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); }); @@ -94,16 +94,16 @@ describe('Google_Cloud_Logging', () => { config: getDefaultConsumerConfig() }); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, getExpectedData([{ jsonPayload: testUtil.deepCopy( context.event.data @@ -119,16 +119,16 @@ describe('Google_Cloud_Logging', () => { config: getDefaultConsumerConfig() }); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, getExpectedData([{ jsonPayload: testUtil.deepCopy( context.event.data @@ -156,16 +156,16 @@ describe('Google_Cloud_Logging', () => { } ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); return cloudLoggingIndex(context); @@ -180,16 +180,16 @@ describe('Google_Cloud_Logging', () => { [{ jsonPayload: testUtil.deepCopy(context.event.data) }] ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); return cloudLoggingIndex(context) @@ -230,16 +230,16 @@ describe('Google_Cloud_Logging', () => { } ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); return cloudLoggingIndex(context); @@ -260,16 +260,16 @@ describe('Google_Cloud_Logging', () => { [{ jsonPayload: testUtil.deepCopy(context.event.data) }] ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); return cloudLoggingIndex(context); @@ -285,17 +285,17 @@ describe('Google_Cloud_Logging', () => { [{ jsonPayload: testUtil.deepCopy(context.event.data) }] ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .times(2) .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('') + .post('/v2/entries:write') .times(3) .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); @@ -333,29 +333,29 @@ describe('Google_Cloud_Logging', () => { [{ jsonPayload: testUtil.deepCopy(contextA.event.data) }] ); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === 'privateKey1::firstKey') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === 'privateKey1::firstKey') .reply(200, { access_token: 'tokenA', expires_in: tokenDuration }); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === 'privateKey2::secondKey') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === 'privateKey2::secondKey') .reply(200, { access_token: 'tokenB', expires_in: tokenDuration }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer tokenA' } }) - .post('') + .post('/v2/entries:write') .times(2) .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: 'Bearer tokenB' } }) - .post('') + .post('/v2/entries:write') .times(2) .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); @@ -376,25 +376,25 @@ describe('Google_Cloud_Logging', () => { ); let tokenCounter = 0; - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .times(2) .reply(200, () => { tokenCounter += 1; return { access_token: `token:${tokenCounter}`, expires_in: tokenDuration }; }); - nock('https://logging.googleapis.com/v2/entries:write', { + nock('https://logging.googleapis.com', { reqheaders: { authorization: (a) => a === `Bearer token:${tokenCounter}` } }) - .post('') + .post('/v2/entries:write') .reply(401, (_, req) => { assert.deepStrictEqual(req, expectedData); return 'Unauthorized'; }) - .post('') + .post('/v2/entries:write') .reply(200, (_, req) => assert.deepStrictEqual(req, expectedData)); return cloudLoggingIndex(context) diff --git a/test/unit/consumers/googleCloudMonitoringConsumerTests.js b/test/unit/consumers/googleCloudMonitoringConsumerTests.js index 66944d3c..f0b8cb80 100644 --- a/test/unit/consumers/googleCloudMonitoringConsumerTests.js +++ b/test/unit/consumers/googleCloudMonitoringConsumerTests.js @@ -30,7 +30,6 @@ const testUtil = require('../shared/util'); const cloudMonitoringIndex = sourceCode('src/lib/consumers/Google_Cloud_Monitoring/index'); const logger = sourceCode('src/lib/logger'); -const tracer = sourceCode('src/lib/utils/tracer'); const tracerMgr = sourceCode('src/lib/tracerManager'); moduleCache.remember(); @@ -161,8 +160,10 @@ describe('Google_Cloud_Monitoring', () => { // Increment persistent time before each test, so any cached tokens are are expired incrementPersistentTime(); - loggerStub = stubs.logger(logger); - tracerStub = stubs.tracer(tracer); + const coreStubs = stubs.default.coreStub({ logger: true, tracer: true }); + + loggerStub = coreStubs.logger; + tracerStub = coreStubs.tracer; // Returned signed JWT in format: privateKeyId::privateKey sinon.stub(jwt, 'sign').callsFake((_, secret, options) => `${options.header.kid}::${secret}`); @@ -173,8 +174,8 @@ describe('Google_Cloud_Monitoring', () => { afterEach(() => tracerMgr.unregisterAll() .then(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); })); @@ -190,32 +191,32 @@ describe('Google_Cloud_Monitoring', () => { }); it('should process systemInfo data', () => { - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .reply(200, {}); return cloudMonitoringIndex(context) @@ -238,32 +239,32 @@ describe('Google_Cloud_Monitoring', () => { }; }); - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', expectedTimeSeries) + .post('/v3/projects/theProject/timeSeries', expectedTimeSeries) .reply(200, {}); context.config.reportInstanceMetadata = true; @@ -276,32 +277,32 @@ describe('Google_Cloud_Monitoring', () => { }); it('should process systemInfo data, when metadata reporting disabled', () => { - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', getOriginTimeSeries()) + .post('/v3/projects/theProject/timeSeries', getOriginTimeSeries()) .reply(200, {}); context.config.reportInstanceMetadata = false; @@ -314,35 +315,35 @@ describe('Google_Cloud_Monitoring', () => { }); it('should process systemInfo data, with a cached access token', () => { - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .times(2) .reply(200, { access_token: 'aToken', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .times(3) .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .times(3) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .times(2) .reply(200, {}); @@ -351,12 +352,12 @@ describe('Google_Cloud_Monitoring', () => { .then(() => { // Increment the clock, to expire token incrementPersistentTime(); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer aToken' } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .times(1) .reply(200, {}); return cloudMonitoringIndex(context); @@ -372,66 +373,66 @@ describe('Google_Cloud_Monitoring', () => { it('should process systemInfo data, with multiple cached access tokens', () => { // Private Key 1 nocks - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === 'privateKey1::firstKey') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === 'privateKey1::firstKey') .reply(200, { access_token: 'accessTokenOne', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenOne' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .times(2) .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenOne' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .times(2) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenOne' } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .times(2) .reply(200, {}); // Private Key 2 nocks - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === 'privateKey2::secondKey') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === 'privateKey2::secondKey') .reply(200, { access_token: 'accessTokenTwo', expires_in: tokenDuration }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenTwo' } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .times(2) .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenTwo' } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .times(2) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: 'Bearer accessTokenTwo' } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .times(2) .reply(200, {}); @@ -454,40 +455,40 @@ describe('Google_Cloud_Monitoring', () => { it('should invalidate token if Unauthorized error on sending data', () => { let tokenCounter = 0; - nock('https://oauth2.googleapis.com/token') - .post('', (body) => body.assertion === '12345::theprivatekeyvalue') + nock('https://oauth2.googleapis.com') + .post('/token', (body) => body.assertion === '12345::theprivatekeyvalue') .times(2) .reply(200, () => { tokenCounter += 1; return { access_token: `token:${tokenCounter}`, expires_in: tokenDuration }; }); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: (a) => a === `Bearer token:${tokenCounter}` } }) - .get('') + .get('/v3/projects/theProject/metricDescriptors') .times(2) .reply(200, metricDescriptors.onGet); - nock('https://monitoring.googleapis.com/v3/projects/theProject/metricDescriptors', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: (a) => a === `Bearer token:${tokenCounter}` } }) - .post('', metricDescriptors.onPost) + .post('/v3/projects/theProject/metricDescriptors', metricDescriptors.onPost) .times(2) .reply(200, {}); - nock('https://monitoring.googleapis.com/v3/projects/theProject/timeSeries', { + nock('https://monitoring.googleapis.com', { reqheaders: { authorization: (a) => a === `Bearer token:${tokenCounter}` } }) - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .reply(401, 'Unauthorized') - .post('', testUtil.deepCopy(getOriginTimeSeries())) + .post('/v3/projects/theProject/timeSeries', testUtil.deepCopy(getOriginTimeSeries())) .reply(200, {}); return cloudMonitoringIndex(context) diff --git a/test/unit/consumers/kafkaConsumerTests.js b/test/unit/consumers/kafkaConsumerTests.js index 1d00d0cb..30f9d0d3 100644 --- a/test/unit/consumers/kafkaConsumerTests.js +++ b/test/unit/consumers/kafkaConsumerTests.js @@ -32,14 +32,30 @@ moduleCache.remember(); describe('Kafka', () => { let sendStub; + let kafkaClientStub; let kafkaProducerStub; let passedClientOptions; + let clientInstance; - let portCount = 9090; + // tests using this will have cached instance of Kafka client const defaultConsumerConfig = { host: 'kafka-host1', port: '9092', - topic: 'dataTopic' + topic: 'dataTopic', + format: 'default', + partitionerType: 'default' + }; + + const defaultProducerOpts = { + partitionerType: 0 + }; + + const defaultRetryOpts = { + retries: 5, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true }; before(() => { @@ -47,11 +63,10 @@ describe('Kafka', () => { }); beforeEach(() => { - defaultConsumerConfig.port = portCount.toString(); - sinon.stub(kafka, 'KafkaClient').callsFake((opts) => { + kafkaClientStub = sinon.stub(kafka, 'KafkaClient').callsFake((opts) => { passedClientOptions = opts; const events = {}; - return { + clientInstance = { on: (event, cb) => { events[event] = cb; }, @@ -59,58 +74,104 @@ describe('Kafka', () => { events[event](data); } }; + return clientInstance; }); - kafkaProducerStub = sinon.stub(kafka, 'Producer').returns({ + kafkaProducerStub = sinon.stub(kafka, 'Producer').callsFake(() => ({ on: (event, cb) => { // No errors, this is a happy place if (event === 'error') { return; } - cb(); }, send: (payload, cb) => sendStub(payload, cb) - }); + })); }); afterEach(() => { - portCount += 1; + clientInstance = null; + sendStub = null; sinon.restore(); }); describe('process', () => { - const expectedPayload = [{ - messages: '', - topic: 'dataTopic' - }]; - - it('should configure Kafka Client client options with default values', (done) => { + it('should configure Kafka Client and Producer options with default values', () => { const context = testUtil.buildConsumerContext({ config: defaultConsumerConfig }); const expectedOptions = { connectTimeout: 3000, - kafkaHost: `kafka-host1:${portCount}`, + kafkaHost: 'kafka-host1:9092', requestTimeout: 5000, sasl: null, - sslOptions: null + sslOptions: null, + connectRetryOptions: defaultRetryOpts }; - sendStub = () => { - try { + return kafkaIndex(context) + .then(() => { assert.deepStrictEqual(passedClientOptions, expectedOptions); - done(); - } catch (err) { - // done() with parameter is treated as an error. - // Use catch back to pass thrown error from assert.deepStrictEqual to done() callback - done(err); + assert.isTrue(kafkaClientStub.calledOnceWithExactly(expectedOptions)); + assert.isTrue(kafkaProducerStub.calledOnceWithExactly(clientInstance, defaultProducerOpts)); + }); + }); + + it('should configure Kafka Client and Producer options with multiple hosts, partitionerType and customOpts', () => { + const context = testUtil.buildConsumerContext({ + config: { + host: ['kafka.set.first', 'kafka.set.second'], + port: '4545', + topic: 'dataTopic', + protocol: 'binaryTcp', + authenticationProtocol: 'SASL-PLAIN', + username: 'myUser', + passphrase: 'mySecret', + customOpts: [ + { + name: 'connectRetryOptions.retries', + value: 8 + }, + { + name: 'connectTimeout', + value: 6000 + }, + { + name: 'maxAsyncRequests', + value: 30 + } + ], + partitionerType: 'cyclic' } + }); + const expectedOptions = { + connectTimeout: 6000, + kafkaHost: 'kafka.set.first:4545,kafka.set.second:4545', + requestTimeout: 5000, + sasl: { + mechanism: 'plain', + password: 'mySecret', + username: 'myUser' + }, + sslOptions: null, + connectRetryOptions: { + retries: 8, + factor: 2, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true + }, + maxAsyncRequests: 30 }; - kafkaIndex(context); + return kafkaIndex(context) + .then(() => { + assert.deepStrictEqual(passedClientOptions, expectedOptions); + assert.isTrue(kafkaClientStub.calledOnceWithExactly(expectedOptions)); + assert.isTrue(kafkaProducerStub.calledOnceWithExactly(clientInstance, { partitionerType: 2 })); + }); }); - it('should configure Kafka Client client options with provided values (authenticationProtocol=SASL-PLAIN)', (done) => { + it('should configure Kafka Client and Producer options with provided values (authenticationProtocol=SASL-PLAIN)', () => { const context = testUtil.buildConsumerContext({ config: { host: 'kafka-second-host', @@ -119,7 +180,8 @@ describe('Kafka', () => { protocol: 'binaryTcpTls', authenticationProtocol: 'SASL-PLAIN', username: 'myUser', - passphrase: 'mySecret' + passphrase: 'mySecret', + partitionerType: 'default' } }); const expectedOptions = { @@ -133,24 +195,19 @@ describe('Kafka', () => { }, sslOptions: { rejectUnauthorized: true - } + }, + connectRetryOptions: defaultRetryOpts }; - sendStub = () => { - try { + return kafkaIndex(context) + .then(() => { assert.deepStrictEqual(passedClientOptions, expectedOptions); - done(); - } catch (err) { - // done() with parameter is treated as an error. - // Use catch back to pass thrown error from assert.deepStrictEqual to done() callback - done(err); - } - }; - - kafkaIndex(context); + assert.isTrue(kafkaClientStub.calledOnceWithExactly(expectedOptions)); + assert.isTrue(kafkaProducerStub.calledOnceWithExactly(clientInstance, defaultProducerOpts)); + }); }); - it('should configure Kafka Client client options with provided values (authenticationProtocol=TLS)', (done) => { + it('should configure Kafka Client and Producer with provided values (authenticationProtocol=TLS)', () => { const context = testUtil.buildConsumerContext({ config: { host: 'kafka.example.com', @@ -160,7 +217,8 @@ describe('Kafka', () => { authenticationProtocol: 'TLS', privateKey: 'privateKey', clientCertificate: 'certificate', - rootCertificate: 'caCert' + rootCertificate: 'caCert', + partitionerType: 'default' } }); const expectedOptions = { @@ -173,12 +231,32 @@ describe('Kafka', () => { key: 'privateKey', cert: 'certificate', ca: 'caCert' - } + }, + connectRetryOptions: defaultRetryOpts }; - sendStub = () => { - try { + return kafkaIndex(context) + .then(() => { assert.deepStrictEqual(passedClientOptions, expectedOptions); + assert.isTrue(kafkaClientStub.calledOnceWithExactly(expectedOptions)); + assert.isTrue(kafkaProducerStub.calledOnceWithExactly(clientInstance, defaultProducerOpts)); + }); + }); + + it('should process systemInfo data (format = "default")', (done) => { + const context = testUtil.buildConsumerContext({ + eventType: 'systemInfo', + config: defaultConsumerConfig + }); + const expectedPayload = [ + { + topic: 'dataTopic', + messages: [JSON.stringify(context.event.data)] + } + ]; + sendStub = (payload) => { + try { + assert.deepStrictEqual(payload, expectedPayload); done(); } catch (err) { // done() with parameter is treated as an error. @@ -190,13 +268,45 @@ describe('Kafka', () => { kafkaIndex(context); }); - it('should process systemInfo data', (done) => { + it('should process systemInfo data (format = "split")', (done) => { const context = testUtil.buildConsumerContext({ eventType: 'systemInfo', config: defaultConsumerConfig }); - expectedPayload[0].messages = JSON.stringify(testUtil.deepCopy(context.event.data)); - + context.event.data = { + system: context.event.data.system, + virtualServers: context.event.data.virtualServers + }; + context.config.format = 'split'; + const origData = testUtil.deepCopy(context.event.data); + const expectedPayload = [ + { + topic: 'dataTopic', + messages: [ + JSON.stringify({ + system: origData.system + }), + JSON.stringify({ + system: { hostname: origData.system.hostname }, + virtualServers: { + '/Common/app.app/app_vs': origData.virtualServers['/Common/app.app/app_vs'] + } + }), + JSON.stringify({ + system: { hostname: origData.system.hostname }, + virtualServers: { + '/Example_Tenant/A1/serviceMain': origData.virtualServers['/Example_Tenant/A1/serviceMain'] + } + }), + JSON.stringify({ + system: { hostname: origData.system.hostname }, + virtualServers: { + '/Example_Tenant/A1/serviceMain-Redirect': origData.virtualServers['/Example_Tenant/A1/serviceMain-Redirect'] + } + }) + ] + } + ]; sendStub = (payload) => { try { assert.deepStrictEqual(payload, expectedPayload); @@ -216,7 +326,12 @@ describe('Kafka', () => { eventType: 'AVR', config: defaultConsumerConfig }); - expectedPayload[0].messages = JSON.stringify(testUtil.deepCopy(context.event.data)); + const expectedPayload = [ + { + messages: [JSON.stringify(testUtil.deepCopy(context.event.data))], + topic: 'dataTopic' + } + ]; sendStub = (payload) => { try { @@ -232,24 +347,34 @@ describe('Kafka', () => { kafkaIndex(context); }); - it('should cache clients between calls', (done) => { + it('should process event data as KeyedMessage (partitionerType = "keyed")', (done) => { + const config = { + host: 'kafka-host1', + port: '9092', + topic: 'dataTopic', + format: 'split', // note that this will not apply to event data + partitionerType: 'keyed', + partitionKey: 'part1' + }; const context = testUtil.buildConsumerContext({ - eventType: 'systemInfo', - config: defaultConsumerConfig + eventType: 'AVR', + config }); - - let repeatCall = false; - let sendCount = 0; - sendStub = () => { - sendCount += 1; - if (!repeatCall) { - repeatCall = true; - return; + const keyedMessage = new kafka.KeyedMessage('part1', JSON.stringify(testUtil.deepCopy(context.event.data))); + delete keyedMessage.timestamp; + const expectedPayload = [ + { + messages: [keyedMessage], + topic: 'dataTopic', + key: 'part1' } + ]; + sendStub = (payload) => { try { - assert.strictEqual(kafka.KafkaClient.callCount, 1, 'should only create 1 Kafka Client'); - assert.strictEqual(sendCount, 2, 'should send data 2 times'); + assert.exists(payload[0].messages[0].timestamp); + delete payload[0].messages[0].timestamp; + assert.deepStrictEqual(payload, expectedPayload); done(); } catch (err) { // done() with parameter is treated as an error. @@ -259,47 +384,47 @@ describe('Kafka', () => { }; kafkaIndex(context); - kafkaIndex(context); }); - it('should not attempt to create multiple Kafka Clients during client initialization', (done) => { + it('should cache Kafka Client between calls', () => { const context = testUtil.buildConsumerContext({ - eventType: 'systemInfo', - config: defaultConsumerConfig + eventType: 'RAW_EVENT', + config: { host: 'kafka.host.cache', port: 9093, topic: 'cacheTopic' } }); - kafkaProducerStub.returns({ - on: (event, cb) => { - // This is not a happy place - if (event === 'error') { - return; - } - setTimeout(cb, 100); - }, - send: (payload, cb) => sendStub(payload, cb) - }); - - let sendCount = 0; - sendStub = () => { - sendCount += 1; - if (sendCount < 3) { - return; - } - - try { - assert.strictEqual(kafka.KafkaClient.callCount, 1, 'should only create 1 Kafka Client'); - assert.strictEqual(sendCount, 3, 'should send data 2 times'); - done(); - } catch (err) { - // done() with parameter is treated as an error. - // Use catch back to pass thrown error from assert.deepStrictEqual to done() callback - done(err); - } + const actualPayloads = []; + sendStub = (payload) => { + actualPayloads.push(payload); }; - kafkaIndex(context); - kafkaIndex(context); - kafkaIndex(context); + context.event.data = 'just.some.sample.data.0'; + return kafkaIndex(context) + .then(() => { + context.event.data = 'just.some.sample.data.1'; + return kafkaIndex(context); + }) + .then(() => { + context.event.data = 'just.some.sample.data.2'; + return kafkaIndex(context); + }) + .then(() => { + assert.isTrue(kafkaClientStub.calledOnce); + assert.isTrue(kafkaProducerStub.calledOnce); + assert.includeDeepMembers(actualPayloads, [ + [{ + topic: 'cacheTopic', + messages: ['just.some.sample.data.0'] + }], + [{ + topic: 'cacheTopic', + messages: ['just.some.sample.data.1'] + }], + [{ + topic: 'cacheTopic', + messages: ['just.some.sample.data.2'] + }] + ]); + }); }); it('should not reject on error connecting to Kafka', () => { @@ -315,7 +440,7 @@ describe('Kafka', () => { const context = testUtil.buildConsumerContext({ eventType: 'systemInfo', - config: defaultConsumerConfig + config: { host: 'kafka.host.error', port: 9099, topic: 'something' } }); return kafkaIndex(context) diff --git a/test/unit/consumers/openTelemetryExporterTests.js b/test/unit/consumers/openTelemetryExporterTests.js index a84ff4f6..e8b783e4 100644 --- a/test/unit/consumers/openTelemetryExporterTests.js +++ b/test/unit/consumers/openTelemetryExporterTests.js @@ -57,7 +57,7 @@ if (IS_8_11_1_PLUS) { moduleCache.remember(); -(IS_8_11_1_PLUS ? describe : describe.skip)('OpenTelemetry_Exporter', () => { +(IS_8_11_1_PLUS ? describe.skip : describe.skip)('OpenTelemetry_Exporter', () => { if (!IS_8_11_1_PLUS) { return; } @@ -136,12 +136,11 @@ moduleCache.remember(); } function initDefaultNockMock(options) { - getMockNock(options).reply(function (_, requestBody) { + getMockNock(options).reply(200, function (_, requestBody) { if (onDataReceivedCallback) { onDataReceivedCallback(requestBody); } requestHeaders = this.req.headers; - return 200; }); } @@ -259,9 +258,9 @@ moduleCache.remember(); }); afterEach(() => { - testUtil.checkNockActiveMocks(nock); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); - nock.cleanAll(); }); describe('OpenTelemetry metrics', () => { diff --git a/test/unit/consumers/splunkConsumerTests.js b/test/unit/consumers/splunkConsumerTests.js index 93aafb53..bb4d16e6 100644 --- a/test/unit/consumers/splunkConsumerTests.js +++ b/test/unit/consumers/splunkConsumerTests.js @@ -19,7 +19,6 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); -const nock = require('nock'); const request = require('request'); const sinon = require('sinon'); const zlib = require('zlib'); @@ -34,7 +33,7 @@ const splunkIndex = sourceCode('src/lib/consumers/Splunk/index'); moduleCache.remember(); -describe('Splunk', () => { +describe.skip('Splunk', () => { let clock; let splunkHost; let splunkPort; @@ -93,8 +92,8 @@ describe('Splunk', () => { afterEach(() => { clock.restore(); + testUtil.nockCleanup(); sinon.restore(); - nock.cleanAll(); }); describe('process', () => { @@ -151,7 +150,7 @@ describe('Splunk', () => { compressionType: 'none' } }); - nock.cleanAll(); + testUtil.nockCleanup(); setupSplunkMockEndpoint(); return splunkIndex(context) .then(() => { @@ -187,7 +186,7 @@ describe('Splunk', () => { } } }); - nock.cleanAll(); + testUtil.nockCleanup(); setupSplunkMockEndpoint({ host: proxyHost, port: proxyPort, @@ -627,7 +626,7 @@ describe('Splunk', () => { describe('proxy options', () => { beforeEach(() => { - nock.cleanAll(); + testUtil.nockCleanup(); }); it('should pass basic proxy options', () => { diff --git a/test/unit/consumersTests.js b/test/unit/consumersTests.js deleted file mode 100644 index 413108df..00000000 --- a/test/unit/consumersTests.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); - -const configWorker = sourceCode('src/lib/config'); -const configUtil = sourceCode('src/lib/utils/config'); -const consumers = sourceCode('src/lib/consumers'); -const moduleLoader = sourceCode('src/lib/utils/moduleLoader').ModuleLoader; - -moduleCache.remember(); - -describe('Consumers', () => { - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - const coreStub = stubs.default.coreStub(); - coreStub.utilMisc.generateUuid.numbersOnly = false; - return configWorker.processDeclaration({ class: 'Telemetry' }); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('config listener', () => { - it('should load required consumers', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }; - return configWorker.processDeclaration(exampleConfig) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.lengthOf(loadedConsumers, 1, 'should load default consumer'); - }); - }); - - it('should not load disabled consumers', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default', - enable: false - } - }; - return configWorker.processDeclaration(exampleConfig) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.isEmpty(loadedConsumers, 'should not load disabled consumer'); - }); - }); - - it('should return empty list of consumers', () => configWorker.emitAsync('change', { components: [], mappings: {} }) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.isEmpty(loadedConsumers); - })); - - it('should unload unrequired consumers', () => { - const priorConfig = { - class: 'Telemetry', - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }; - return configWorker.processDeclaration(priorConfig) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.lengthOf(loadedConsumers, 1, 'should load default consumer'); - return configWorker.emitAsync('change', { components: [], mappings: {} }); - }) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.isEmpty(Object.keys(loadedConsumers), 'should unload default consumer'); - }); - }); - - it('should fail to load invalid pull consumer types (consumerType=unknowntype)', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'unknowntype' - } - }; - // config will not pass schema validation - // but this test allows catching if consumer module/dir is not configured properly - return configUtil.normalizeDeclaration(exampleConfig) - .then((normalized) => configWorker.emitAsync('change', normalized)) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.strictEqual( - Object.keys(loadedConsumers).indexOf('unknowntype'), - -1, - 'should not load invalid consumer type' - ); - }); - }); - - it('should not reload existing consumer when processing a new namespace declaration', () => { - let existingConsumer; - const existingConfig = { - class: 'Telemetry', - FirstConsumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }; - - const namespaceConfig = { - class: 'Telemetry_Namespace', - SecondConsumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }; - const moduleLoaderSpy = sinon.spy(moduleLoader, 'load'); - return configWorker.processDeclaration(existingConfig) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.lengthOf(loadedConsumers, 1, 'should load default consumer'); - assert.isTrue(moduleLoaderSpy.calledOnce); - existingConsumer = loadedConsumers[0]; - }) - .then(() => configWorker.processNamespaceDeclaration(namespaceConfig, 'NewNamespace')) - .then(() => { - const loadedConsumers = consumers.getConsumers(); - assert.lengthOf(loadedConsumers, 2, 'should load new consumer too'); - assert.strictEqual(loadedConsumers[0].id, existingConsumer.id); - assert.strictEqual(loadedConsumers[1].id, 'NewNamespace::SecondConsumer'); - assert.isTrue(moduleLoaderSpy.calledTwice); - }); - }); - }); -}); diff --git a/test/unit/customEndpointsTests.js b/test/unit/customEndpointsTests.js deleted file mode 100644 index 106e2094..00000000 --- a/test/unit/customEndpointsTests.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const nock = require('nock'); - -const assert = require('./shared/assert'); -const customEndpointsTestsData = require('./data/customEndpointsTestsData'); -const sourceCode = require('./shared/sourceCode'); -const testUtil = require('./shared/util'); - -const SystemStats = sourceCode('src/lib/systemStats'); - -moduleCache.remember(); - -describe('Custom Endpoints (Telemetry_Endpoints)', () => { - before(() => { - moduleCache.restore(); - }); - - const DEFAULT_TOTAL_ATTEMPTS = 10; - - const checkResponse = (endpointMock, response) => { - if (!response.kind && !endpointMock.skipCheckResponse) { - throw new Error(`Endpoint '${endpointMock.endpoint}' has no property 'kind' in response`); - } - }; - - Object.keys(customEndpointsTestsData).forEach((testSetKey) => { - const testSet = customEndpointsTestsData[testSetKey]; - - testUtil.getCallableDescribe(testSet)(testSet.name, () => { - afterEach(() => { - nock.cleanAll(); - }); - - testSet.tests.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => { - const endpointsStateValidator = testUtil.getSpoiledDataValidator(testConf.endpointList); - - const totalAttempts = testConf.totalAttempts || DEFAULT_TOTAL_ATTEMPTS; - - const options = { - endpoints: testConf.endpointList - }; - const getCollectedData = testConf.getCollectedData - ? testConf.getCollectedData : (promise) => promise; - - const stats = new SystemStats(options); - - let promise = Promise.resolve(); - for (let i = 1; i < totalAttempts + 1; i += 1) { - promise = promise.then(() => { - testUtil.mockEndpoints(testConf.endpoints || [], { responseChecker: checkResponse }); - return getCollectedData(stats.collect(), stats); - }) - .then((data) => { - assert.deepStrictEqual(data, testConf.expectedData, `should match expected output (attempt #${i})`); - assert.isEmpty(stats.loader.cachedResponse, `cache should be erased (attempt #${i})`); - endpointsStateValidator(); - }); - } - return promise; - }); - }); - }); - }); -}); diff --git a/test/unit/data/configUtilTests/getComponentsTestsData.js b/test/unit/data/configUtilTests/getComponentsTestsData.js index 416d6875..e69818c1 100644 --- a/test/unit/data/configUtilTests/getComponentsTestsData.js +++ b/test/unit/data/configUtilTests/getComponentsTestsData.js @@ -23,520 +23,341 @@ * - UUID */ +const allIDs = [ + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller::poller' + }, + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller::poller' + }, + { + class: 'Telemetry_Consumer', + id: 'f5telemetry_default::consumer' + }, + { + class: 'Telemetry_Consumer', + id: 'namespace::consumer' + }, + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller2::poller2' + }, + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller2::poller2' + }, + { + class: 'Telemetry_Consumer', + id: 'f5telemetry_default::consumer2' + }, + { + class: 'Telemetry_Consumer', + id: 'namespace::consumer2' + }, + { + class: 'Telemetry_Listener', + id: 'f5telemetry_default::listener' + }, + { + class: 'Telemetry_Listener', + id: 'namespace::listener' + } +]; + module.exports = { name: '.getComponents()', - tests: [ - { - name: 'should return all components', - declaration: { - class: 'Telemetry', - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - } - } + allIDs, + declaration: { + class: 'Telemetry', + consumer: { + class: 'Telemetry_Consumer', + type: 'default' + }, + consumer2: { + class: 'Telemetry_Consumer', + type: 'default' + }, + listener: { + class: 'Telemetry_Listener' + }, + poller: { + class: 'Telemetry_System_Poller' + }, + poller2: { + class: 'Telemetry_System_Poller' + }, + namespace: { + class: 'Telemetry_Namespace', + consumer: { + class: 'Telemetry_Consumer', + type: 'default' + }, + consumer2: { + class: 'Telemetry_Consumer', + type: 'default' + }, + listener: { + class: 'Telemetry_Listener' + }, + poller: { + class: 'Telemetry_System_Poller' + }, + poller2: { + class: 'Telemetry_System_Poller' + } + } + }, + params: { + class: [ + { + filter: undefined, + expected: allIDs }, - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' + { + filter: 'Telemetry_Consumer', + expected: [ + { + class: 'Telemetry_Consumer', + id: 'f5telemetry_default::consumer' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_1', - name: 'Consumer_1', - traceName: 'My_Namespace::Consumer_1', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_1', - type: 'output' + { + class: 'Telemetry_Consumer', + id: 'namespace::consumer' }, - type: 'default' - } - ] - }, - { - name: 'should filter components by class (string)', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer2' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer2' } - } + ] }, - classFilter: 'Telemetry_Consumer', - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' + { + filter: ['Telemetry_Consumer', 'Telemetry_System_Poller'], + expected: [ + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller::poller' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_2', - name: 'Consumer_2', - traceName: 'f5telemetry_default::Consumer_2', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_2', - type: 'output' + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller::poller' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_1', - name: 'Consumer_1', - traceName: 'My_Namespace::Consumer_1', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_1', - type: 'output' + { + class: 'Telemetry_Consumer', + id: 'f5telemetry_default::consumer' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_2', - name: 'Consumer_2', - traceName: 'My_Namespace::Consumer_2', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_2', - type: 'output' + { + class: 'Telemetry_Consumer', + id: 'namespace::consumer' }, - type: 'default' - } - ] - }, - { - name: 'should filter components by class (function)', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller2::poller2' + }, + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller2::poller2' + }, + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer2' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer2' } - } + ] }, - classFilter: (c) => c.name === 'Consumer_1', - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' - }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_1', - name: 'Consumer_1', - traceName: 'My_Namespace::Consumer_1', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_1', - type: 'output' + { + filter: (name) => name.endsWith('Listener'), + expected: [ + { + class: 'Telemetry_Listener', + id: 'f5telemetry_default::listener' }, - type: 'default' - } - ] - }, - { - name: 'should filter components by namespace (string)', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { + class: 'Telemetry_Listener', + id: 'namespace::listener' + } + ] + }, + { + filter: 'something', + expected: [] + }, + { + filter: ['something'], + expected: [] + }, + { + filter: (v) => v === 'something', + expected: [] + } + ], + name: [ + { + filter: undefined, + expected: allIDs + }, + { + filter: 'consumer', + expected: [ + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer' } - } + ] }, - namespaceFilter: 'f5telemetry_default', - expected: [ - { - class: 'Telemetry_Listener', - actions: [ - { - enable: true, - setTag: { - application: '`A`', - tenant: '`T`' - } - } - ], - enable: true, - id: 'f5telemetry_default::Listener1', - match: '', - name: 'Listener1', - namespace: 'f5telemetry_default', - port: 6514, - tag: {}, - traceInput: { - enable: false, - encoding: 'utf8', - maxRecords: 9999, - path: '/var/tmp/telemetry/INPUT.Telemetry_Listener.f5telemetry_default::Listener1', - type: 'input' - }, - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Listener.f5telemetry_default::Listener1', - type: 'output' - }, - traceName: 'f5telemetry_default::Listener1' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' + { + filter: ['consumer', 'poller'], + expected: [ + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller::poller' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_2', - name: 'Consumer_2', - traceName: 'f5telemetry_default::Consumer_2', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_2', - type: 'output' + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller::poller' }, - type: 'default' - } - ] - }, - { - name: 'should filter components by namespace (function)', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer' } - } + ] }, - namespaceFilter: (c) => c.namespace === 'My_Namespace', - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_1', - name: 'Consumer_1', - traceName: 'My_Namespace::Consumer_1', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_1', - type: 'output' + { + filter: (name) => name === 'listener', + expected: [ + { + class: 'Telemetry_Listener', + id: 'f5telemetry_default::listener' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_2', - name: 'Consumer_2', - traceName: 'My_Namespace::Consumer_2', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_2', - type: 'output' + { + class: 'Telemetry_Listener', + id: 'namespace::listener' + } + ] + }, + { + filter: 'something', + expected: [] + }, + { + filter: ['something'], + expected: [] + }, + { + filter: (v) => v === 'something', + expected: [] + } + ], + namespace: [ + { + filter: undefined, + expected: allIDs + }, + { + filter: 'f5telemetry_default', + expected: [ + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller::poller' }, - type: 'default' - } - ] - }, - { - name: 'should filter components by namespace and class', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { + class: 'Telemetry_System_Poller', + id: 'f5telemetry_default::poller2::poller2' + }, + { + class: 'Telemetry_Listener', + id: 'f5telemetry_default::listener' + }, + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'f5telemetry_default::consumer2' } - } + ] }, - classFilter: 'Telemetry_Consumer', - namespaceFilter: (c) => c.namespace === 'f5telemetry_default', - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' + { + filter: ['f5telemetry_default', 'namespace'], + expected: allIDs + }, + { + filter: (name) => name === 'namespace', + expected: [ + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller::poller' }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_2', - name: 'Consumer_2', - traceName: 'f5telemetry_default::Consumer_2', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_2', - type: 'output' + { + class: 'Telemetry_System_Poller', + id: 'namespace::poller2::poller2' }, - type: 'default' - } - ] - }, - { - name: 'should filter components using arbitrary function', - declaration: { - class: 'Telemetry', - Listener1: { - class: 'Telemetry_Listener' - }, - Consumer_1: { - class: 'Telemetry_Consumer', - type: 'default' - }, - Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Consumer_1: { + { + class: 'Telemetry_Listener', + id: 'namespace::listener' + }, + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer' }, - Consumer_2: { + { class: 'Telemetry_Consumer', - type: 'default' + id: 'namespace::consumer2' } - } + ] }, - filter: (c) => c.name === 'Consumer_1', - expected: [ - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'f5telemetry_default::Consumer_1', - name: 'Consumer_1', - traceName: 'f5telemetry_default::Consumer_1', - namespace: 'f5telemetry_default', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.f5telemetry_default::Consumer_1', - type: 'output' - }, - type: 'default' - }, - { - allowSelfSignedCert: false, - class: 'Telemetry_Consumer', - enable: true, - id: 'My_Namespace::Consumer_1', - name: 'Consumer_1', - traceName: 'My_Namespace::Consumer_1', - namespace: 'My_Namespace', - trace: { - enable: false, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_Consumer.My_Namespace::Consumer_1', - type: 'output' + { + filter: 'something', + expected: [] + }, + { + filter: ['something'], + expected: [] + }, + { + filter: (v) => v === 'something', + expected: [] + } + ], + filter: [ + { + filter: undefined, + expected: allIDs + }, + { + filter: (c) => c.name === 'consumer', + expected: [ + { + class: 'Telemetry_Consumer', + id: 'f5telemetry_default::consumer' }, - type: 'default' - } - ] - } - ] + { + class: 'Telemetry_Consumer', + id: 'namespace::consumer' + } + ] + }, + { + filter: (c) => c.name === 'consumer-something', + expected: [] + } + ] + } }; diff --git a/test/unit/data/configUtilTests/hasEnabledComponentsTestsData.js b/test/unit/data/configUtilTests/hasEnabledComponentsTestsData.js index cc7d3e27..7868149b 100644 --- a/test/unit/data/configUtilTests/hasEnabledComponentsTestsData.js +++ b/test/unit/data/configUtilTests/hasEnabledComponentsTestsData.js @@ -106,7 +106,6 @@ module.exports = { } } }, - classFilter: 'Telemetry_Consumer', expected: true }, { @@ -140,7 +139,6 @@ module.exports = { } } }, - classFilter: (c) => c.name === 'Consumer_1', expected: true }, { @@ -174,7 +172,6 @@ module.exports = { } } }, - namespaceFilter: 'f5telemetry_default', expected: true }, { @@ -208,7 +205,6 @@ module.exports = { } } }, - namespaceFilter: (c) => c.namespace === 'My_Namespace', expected: true }, { @@ -242,8 +238,6 @@ module.exports = { } } }, - classFilter: 'Telemetry_Consumer', - namespaceFilter: (c) => c.namespace === 'f5telemetry_default', expected: true }, { @@ -277,7 +271,6 @@ module.exports = { } } }, - filter: (c) => c.name === 'Consumer_1', expected: true } ] diff --git a/test/unit/data/configUtilTests/normalizeDeclarationEndpointsTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationEndpointsTestsData.js index ba846e20..974975fd 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationEndpointsTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationEndpointsTestsData.js @@ -78,6 +78,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::My_System::SystemPoller_1', name: 'SystemPoller_1', @@ -107,18 +109,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: {} }, { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::My_System::SystemPoller_1', name: 'SystemPoller_1', @@ -148,12 +147,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: {} } @@ -215,6 +209,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::My_System::SystemPoller_1', name: 'SystemPoller_1', @@ -244,12 +240,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { endpoint1: { @@ -277,6 +268,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::My_System::SystemPoller_1', name: 'SystemPoller_1', @@ -306,12 +299,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { endpoint1: { @@ -667,6 +655,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::My_System_1::SystemPoller_1', name: 'SystemPoller_1', @@ -696,12 +686,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { @@ -722,6 +707,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::My_System_2::SystemPoller_1', name: 'SystemPoller_1', @@ -751,12 +738,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { @@ -816,6 +798,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::My_System_3::SystemPoller_1', name: 'SystemPoller_1', @@ -845,12 +829,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { @@ -884,6 +863,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::My_System_1::SystemPoller_1', name: 'SystemPoller_1', @@ -913,12 +894,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { @@ -939,6 +915,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::My_System_2::SystemPoller_1', name: 'SystemPoller_1', @@ -968,12 +946,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { @@ -1033,6 +1006,8 @@ module.exports = { { enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::My_System_3::SystemPoller_1', name: 'SystemPoller_1', @@ -1062,12 +1037,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, endpoints: { enabledEndpoint1: { diff --git a/test/unit/data/configUtilTests/normalizeDeclarationIHealthPollerTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationIHealthPollerTestsData.js index 633076d1..53504576 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationIHealthPollerTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationIHealthPollerTestsData.js @@ -34,6 +34,10 @@ module.exports = { class: 'Telemetry_System', host: 'host1', enable: true, + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, iHealthPoller: { username: 'test_user_1', passphrase: { @@ -44,10 +48,17 @@ module.exports = { start: '23:15', end: '02:15' } + }, + proxy: { + host: 'proxyhost', + port: 5555, + protocol: 'https' } }, systemPoller: { - interval: 60 + interval: 60, + workers: 6, + chunkSize: 60 } }, My_Consumer: { @@ -70,10 +81,22 @@ module.exports = { start: '23:15', end: '02:15' } + }, + proxy: { + host: 'proxyhost', + port: 5555, + protocol: 'https', + allowSelfSignedCert: true, + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_2' + } } }, systemPoller: { - interval: 60 + interval: 60, + workers: 6, + chunkSize: 60 } }, My_Consumer: { @@ -99,8 +122,12 @@ module.exports = { protocol: 'http' }, credentials: { - passphrase: undefined, - username: undefined + passphrase: { + cipherText: '$M$test_passphrase_1', + class: 'Secret', + protected: 'SecureVault' + }, + username: 'test_user_1' }, dataOpts: { actions: [ @@ -112,12 +139,13 @@ module.exports = { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, enable: true, id: 'f5telemetry_default::My_System::SystemPoller_1', interval: 60, + workers: 6, + chunkSize: 60, name: 'SystemPoller_1', namespace: 'f5telemetry_default', systemName: 'My_System', @@ -151,9 +179,8 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', @@ -162,33 +189,32 @@ module.exports = { }, proxy: { connection: { - host: undefined, - port: undefined, - protocol: undefined, + host: 'proxyhost', + port: 5555, + protocol: 'https', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } } }, system: { - host: 'host1', name: 'My_System', connection: { + host: 'host1', port: 8100, protocol: 'http', allowSelfSignedCert: false }, credentials: { - username: undefined, - passphrase: undefined + passphrase: { + cipherText: '$M$test_passphrase_1', + class: 'Secret', + protected: 'SecureVault' + }, + username: 'test_user_1' } }, id: 'f5telemetry_default::My_System::iHealthPoller_1', namespace: 'f5telemetry_default', - systemName: 'My_System', traceName: 'f5telemetry_default::My_System::iHealthPoller_1' }, { @@ -216,10 +242,6 @@ module.exports = { port: 8100, protocol: 'http' }, - credentials: { - passphrase: undefined, - username: undefined - }, dataOpts: { actions: [ { @@ -230,12 +252,13 @@ module.exports = { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, enable: true, id: 'My_Namespace::My_System::SystemPoller_1', interval: 60, + workers: 6, + chunkSize: 60, name: 'SystemPoller_1', namespace: 'My_Namespace', systemName: 'My_System', @@ -269,9 +292,8 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', @@ -280,32 +302,31 @@ module.exports = { }, proxy: { connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false + host: 'proxyhost', + port: 5555, + protocol: 'https', + allowSelfSignedCert: true }, credentials: { - username: undefined, - passphrase: undefined + username: 'test_user_1', + passphrase: { + cipherText: '$M$test_passphrase_2', + class: 'Secret', + protected: 'SecureVault' + } } } }, system: { - host: 'host2', name: 'My_System', connection: { + host: 'host2', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System::iHealthPoller_1' }, @@ -349,6 +370,10 @@ module.exports = { start: '23:15', end: '02:15' } + }, + proxy: { + host: 'proxyhost', + username: 'test_user_1' } }, My_Consumer: { @@ -407,9 +432,8 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', @@ -418,33 +442,27 @@ module.exports = { }, proxy: { connection: { - host: undefined, - port: undefined, - protocol: undefined, + host: 'proxyhost', + port: 80, + protocol: 'http', allowSelfSignedCert: false }, credentials: { - username: undefined, - passphrase: undefined + username: 'test_user_1' } } }, system: { - host: 'host1', name: 'My_System', connection: { + host: 'host1', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System', namespace: 'f5telemetry_default', traceName: 'f5telemetry_default::My_System::My_iHealth_Poller' }, @@ -485,44 +503,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host2', name: 'My_System', connection: { + host: 'host2', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System::My_iHealth_Poller' }, @@ -646,45 +646,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host1', name: 'My_System_1', connection: { + host: 'host1', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_1::My_iHealth_Poller', name: 'My_iHealth_Poller', namespace: 'f5telemetry_default', - systemName: 'My_System_1', traceName: 'f5telemetry_default::My_System_1::My_iHealth_Poller' }, { @@ -707,45 +689,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host2', name: 'My_System_2', connection: { + host: 'host2', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', namespace: 'f5telemetry_default', - systemName: 'My_System_2', traceName: 'f5telemetry_default::My_System_2::My_iHealth_Poller' }, { @@ -785,45 +749,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host3', name: 'My_System_1', connection: { + host: 'host3', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System_1::My_iHealth_Poller', name: 'My_iHealth_Poller', namespace: 'My_Namespace', - systemName: 'My_System_1', traceName: 'My_Namespace::My_System_1::My_iHealth_Poller' }, { @@ -846,45 +792,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host4', name: 'My_System_2', connection: { + host: 'host4', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', namespace: 'My_Namespace', - systemName: 'My_System_2', traceName: 'My_Namespace::My_System_2::My_iHealth_Poller' }, { @@ -1070,44 +998,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System::iHealthPoller_1', namespace: 'f5telemetry_default', - systemName: 'My_System', traceName: 'f5telemetry_default::My_System::iHealthPoller_1' }, { @@ -1130,44 +1040,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System_2', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'f5telemetry_default', traceName: 'f5telemetry_default::My_System_2::My_iHealth_Poller' }, @@ -1192,43 +1084,25 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System::iHealthPoller_1' }, @@ -1252,44 +1126,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System_2', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System_2::My_iHealth_Poller' } @@ -1455,44 +1311,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host1', name: 'My_System', connection: { + host: 'host1', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System::iHealthPoller_1', namespace: 'f5telemetry_default', - systemName: 'My_System', traceName: 'f5telemetry_default::My_System::iHealthPoller_1' }, { @@ -1515,44 +1353,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host2', name: 'My_System_2', connection: { + host: 'host2', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'f5telemetry_default', traceName: 'f5telemetry_default::My_System_2::My_iHealth_Poller' }, @@ -1594,43 +1414,25 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host3', name: 'My_System', connection: { + host: 'host3', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System::iHealthPoller_1' }, @@ -1654,44 +1456,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host4', name: 'My_System_2', connection: { + host: 'host4', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System_2::My_iHealth_Poller' }, @@ -1733,43 +1517,25 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host5', name: 'My_System', connection: { + host: 'host5', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace_2::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace_2', traceName: 'My_Namespace_2::My_System::iHealthPoller_1' }, @@ -1793,44 +1559,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host6', name: 'My_System_2', connection: { + host: 'host6', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace_2::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'My_Namespace_2', traceName: 'My_Namespace_2::My_System_2::My_iHealth_Poller' }, @@ -1870,7 +1618,8 @@ module.exports = { timeWindow: { start: '23:15', end: '02:15' - } + }, + frequency: 'daily' } } }, @@ -1884,7 +1633,9 @@ module.exports = { timeWindow: { start: '23:15', end: '02:15' - } + }, + frequency: 'weekly', + day: 7 } }, My_System_2: { @@ -1913,7 +1664,9 @@ module.exports = { timeWindow: { start: '23:15', end: '02:15' - } + }, + frequency: 'weekly', + day: 'wednesday' } } }, @@ -1927,7 +1680,9 @@ module.exports = { timeWindow: { start: '23:15', end: '02:15' - } + }, + frequency: 'monthly', + day: 30 } }, My_System_2: { @@ -2019,44 +1774,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System::iHealthPoller_1', namespace: 'f5telemetry_default', - systemName: 'My_System', traceName: 'f5telemetry_default::My_System::iHealthPoller_1' }, { @@ -2079,44 +1816,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, - frequency: 'daily', + day: 7, + frequency: 'weekly', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System_2', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'f5telemetry_default', traceName: 'f5telemetry_default::My_System_2::My_iHealth_Poller' }, @@ -2175,43 +1895,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, - frequency: 'daily', + day: 'wednesday', + frequency: 'weekly', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System::iHealthPoller_1' }, @@ -2235,44 +1938,27 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, - frequency: 'daily', + day: 30, + frequency: 'monthly', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System_2', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'My_Namespace', traceName: 'My_Namespace::My_System_2::My_iHealth_Poller' }, @@ -2331,43 +2017,25 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace_2::My_System::iHealthPoller_1', - systemName: 'My_System', namespace: 'My_Namespace_2', traceName: 'My_Namespace_2::My_System::iHealthPoller_1' }, @@ -2391,44 +2059,26 @@ module.exports = { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'localhost', name: 'My_System_2', connection: { + host: 'localhost', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'My_Namespace_2::My_System_2::My_iHealth_Poller', name: 'My_iHealth_Poller', - systemName: 'My_System_2', namespace: 'My_Namespace_2', traceName: 'My_Namespace_2::My_System_2::My_iHealth_Poller' }, diff --git a/test/unit/data/configUtilTests/normalizeDeclarationPullConsumerTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationPullConsumerTestsData.js index 458303f8..834b66bf 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationPullConsumerTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationPullConsumerTestsData.js @@ -67,7 +67,9 @@ module.exports = { 'Pull_Poller_1', 'Pull_Poller_2', { - interval: 90 + interval: 90, + workers: 9, + chunkSize: 9 }, 'Regular_Poller_1' ] @@ -136,7 +138,9 @@ module.exports = { 'Pull_Poller_1', 'Pull_Poller_2', { - interval: 90 + interval: 90, + workers: 9, + chunkSize: 9 }, 'Regular_Poller_1' ] @@ -204,6 +208,7 @@ module.exports = { enable: false }, pullConsumer: 'f5telemetry_default::My_Pull_Consumer', + pullConsumerName: 'f5telemetry_default::My_Pull_Consumer', systemPollers: [ 'f5telemetry_default::My_System::Pull_Poller_1', 'f5telemetry_default::My_System_2::Pull_Poller_2', @@ -239,6 +244,7 @@ module.exports = { enable: false }, pullConsumer: 'f5telemetry_default::My_Disabled_Pull_Consumer', + pullConsumerName: 'f5telemetry_default::My_Disabled_Pull_Consumer', systemPollers: [ 'f5telemetry_default::My_System::Pull_Poller_1', 'f5telemetry_default::My_System_2::Pull_Poller_2', @@ -274,6 +280,7 @@ module.exports = { enable: false }, pullConsumer: 'My_Namespace::My_Pull_Consumer', + pullConsumerName: 'My_Namespace::My_Pull_Consumer', systemPollers: [ 'My_Namespace::My_System::Pull_Poller_1', 'My_Namespace::My_System_2::Pull_Poller_2', @@ -309,6 +316,7 @@ module.exports = { enable: false }, pullConsumer: 'My_Namespace::My_Disabled_Pull_Consumer', + pullConsumerName: 'My_Namespace::My_Disabled_Pull_Consumer', systemPollers: [ 'My_Namespace::My_System::Pull_Poller_1', 'My_Namespace::My_System_2::Pull_Poller_2', @@ -321,6 +329,8 @@ module.exports = { { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'f5telemetry_default', @@ -349,18 +359,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System::Pull_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_2', namespace: 'f5telemetry_default', @@ -389,18 +396,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System_2::Pull_Poller_2' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'f5telemetry_default', @@ -429,18 +433,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System_3::Pull_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_2', namespace: 'f5telemetry_default', @@ -469,17 +470,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System_3::Pull_Poller_2' }, { interval: 90, + workers: 9, + chunkSize: 9, enable: true, name: 'SystemPoller_1', class: 'Telemetry_System_Poller', @@ -509,18 +507,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System_3::SystemPoller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Regular_Poller_1', namespace: 'f5telemetry_default', @@ -549,18 +544,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::My_System_3::Regular_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'My_Namespace', @@ -589,18 +581,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System::Pull_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_2', namespace: 'My_Namespace', @@ -629,18 +618,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System_2::Pull_Poller_2' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'My_Namespace', @@ -669,18 +655,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System_3::Pull_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_2', namespace: 'My_Namespace', @@ -709,17 +692,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System_3::Pull_Poller_2' }, { interval: 90, + workers: 9, + chunkSize: 9, enable: true, name: 'SystemPoller_1', class: 'Telemetry_System_Poller', @@ -749,18 +729,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System_3::SystemPoller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Regular_Poller_1', namespace: 'My_Namespace', @@ -789,18 +766,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::My_System_3::Regular_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_3', namespace: 'f5telemetry_default', @@ -829,18 +803,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::Pull_Poller_3::Pull_Poller_3' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_4', namespace: 'f5telemetry_default', @@ -869,18 +840,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::Pull_Poller_4::Pull_Poller_4' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_3', namespace: 'My_Namespace', @@ -909,18 +877,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::Pull_Poller_3::Pull_Poller_3' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_4', namespace: 'My_Namespace', @@ -949,12 +914,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::Pull_Poller_4::Pull_Poller_4' } @@ -1023,6 +983,7 @@ module.exports = { enable: false }, pullConsumer: 'f5telemetry_default::My_Pull_Consumer', + pullConsumerName: 'f5telemetry_default::My_Pull_Consumer', systemPollers: [ 'f5telemetry_default::Pull_Poller_1::Pull_Poller_1' ], @@ -1054,6 +1015,7 @@ module.exports = { enable: false }, pullConsumer: 'My_Namespace::My_Pull_Consumer', + pullConsumerName: 'My_Namespace::My_Pull_Consumer', systemPollers: [ 'My_Namespace::Pull_Poller_1::Pull_Poller_1' ], @@ -1062,6 +1024,8 @@ module.exports = { { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'f5telemetry_default', @@ -1090,18 +1054,15 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'f5telemetry_default::Pull_Poller_1::Pull_Poller_1' }, { class: 'Telemetry_System_Poller', interval: 0, + workers: 5, + chunkSize: 30, enable: true, name: 'Pull_Poller_1', namespace: 'My_Namespace', @@ -1130,12 +1091,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace::Pull_Poller_1::Pull_Poller_1' } diff --git a/test/unit/data/configUtilTests/normalizeDeclarationSystemPollerTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationSystemPollerTestsData.js index 7f37e8c8..7bef58ee 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationSystemPollerTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationSystemPollerTestsData.js @@ -37,6 +37,8 @@ module.exports = { class: 'Telemetry_System_Poller', trace: true, interval: 600, + workers: 6, + chunkSize: 60, port: 8102, host: 'host2', enable: true, @@ -61,13 +63,12 @@ module.exports = { class: 'Telemetry_System_Poller', trace: true, interval: 600, + workers: 6, + chunkSize: 60, host: 'host4', port: 8102, enable: true, username: 'username2', - passphrase: { - cipherText: 'passphrase2' - }, tag: { tag: 'tag2' } @@ -96,6 +97,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, enable: true, name: 'My_Poller_1', id: 'f5telemetry_default::My_Poller_1::My_Poller_1', @@ -118,12 +121,7 @@ module.exports = { enable: true } ], - tags: undefined, noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -136,6 +134,8 @@ module.exports = { type: 'output' }, interval: 600, + workers: 6, + chunkSize: 60, enable: true, name: 'My_Poller_2', id: 'f5telemetry_default::My_Poller_2::My_Poller_2', @@ -199,6 +199,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, enable: true, name: 'My_Poller_1', id: 'My_Namespace::My_Poller_1::My_Poller_1', @@ -221,12 +223,7 @@ module.exports = { enable: true } ], - tags: undefined, noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -239,6 +236,8 @@ module.exports = { type: 'output' }, interval: 600, + workers: 6, + chunkSize: 60, enable: true, name: 'My_Poller_2', id: 'My_Namespace::My_Poller_2::My_Poller_2', @@ -267,12 +266,7 @@ module.exports = { noTMStats: true }, credentials: { - username: 'username2', - passphrase: { - cipherText: '$M$passphrase2', - class: 'Secret', - protected: 'SecureVault' - } + username: 'username2' } }, { @@ -317,6 +311,8 @@ module.exports = { class: 'Telemetry_System_Poller', trace: true, interval: 500, + workers: 5, + chunkSize: 50, enable: true }, My_Consumer_1: { @@ -343,6 +339,8 @@ module.exports = { class: 'Telemetry_System_Poller', trace: true, interval: 500, + workers: 5, + chunkSize: 50, enable: true }, My_Consumer_1: { @@ -367,6 +365,8 @@ module.exports = { type: 'output' }, interval: 500, + workers: 5, + chunkSize: 50, enable: false, // system value kept name: 'My_Poller_1', id: 'f5telemetry_default::My_System_1::My_Poller_1', @@ -389,12 +389,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { @@ -407,6 +402,8 @@ module.exports = { type: 'output' }, interval: 500, + workers: 5, + chunkSize: 50, enable: true, name: 'My_Poller_1', id: 'f5telemetry_default::My_System_2::My_Poller_1', @@ -429,12 +426,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { @@ -464,6 +456,8 @@ module.exports = { type: 'output' }, interval: 500, + workers: 5, + chunkSize: 50, enable: false, // system value kept name: 'My_Poller_1', id: 'My_Namespace::My_System_1::My_Poller_1', @@ -486,12 +480,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { @@ -504,6 +493,8 @@ module.exports = { type: 'output' }, interval: 500, + workers: 5, + chunkSize: 50, enable: true, name: 'My_Poller_1', id: 'My_Namespace::My_System_2::My_Poller_1', @@ -526,12 +517,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { @@ -570,6 +556,8 @@ module.exports = { host: 'host1', port: 443, interval: 10, + workers: 10, + chunkSize: 10, protocol: 'https', tag: { tag: 'tag' @@ -616,6 +604,8 @@ module.exports = { host: 'host2', port: 443, interval: 10, + workers: 10, + chunkSize: 10, protocol: 'https', tag: { tag: 'tag' @@ -659,6 +649,8 @@ module.exports = { type: 'output' }, interval: 10, + workers: 10, + chunkSize: 10, endpoints: { endpoint1: { path: '/endpoint1', @@ -743,6 +735,8 @@ module.exports = { type: 'output' }, interval: 10, + workers: 10, + chunkSize: 10, endpoints: { endpoint1: { path: '/endpoint1', @@ -834,6 +828,8 @@ module.exports = { host: 'host2', port: 443, interval: 10, + workers: 10, + chunkSize: 10, protocol: 'https', tag: { tag: 'tag' @@ -881,6 +877,8 @@ module.exports = { host: 'host4', port: 443, interval: 10, + workers: 10, + chunkSize: 10, protocol: 'https', tag: { tag: 'tag' @@ -924,6 +922,8 @@ module.exports = { type: 'output' }, interval: 10, + workers: 10, + chunkSize: 10, endpoints: { endpoint1: { path: '/endpoint1', @@ -963,10 +963,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -997,6 +993,8 @@ module.exports = { type: 'output' }, interval: 10, + workers: 10, + chunkSize: 10, endpoints: { endpoint1: { path: '/endpoint1', @@ -1036,10 +1034,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1153,6 +1147,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, name: 'Poller_With_Actions', id: 'f5telemetry_default::My_System::Poller_With_Actions', namespace: 'f5telemetry_default', @@ -1165,7 +1161,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { excludeData: {}, @@ -1189,10 +1184,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1223,6 +1214,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, name: 'Poller_With_Actions', id: 'My_Namespace::My_System::Poller_With_Actions', namespace: 'My_Namespace', @@ -1235,7 +1228,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { excludeData: {}, @@ -1259,10 +1251,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1372,6 +1360,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System::SystemPoller_1', namespace: 'f5telemetry_default', @@ -1384,7 +1374,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { excludeData: {}, @@ -1408,10 +1397,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1442,6 +1427,8 @@ module.exports = { type: 'output' }, interval: 300, + workers: 5, + chunkSize: 30, name: 'SystemPoller_1', id: 'My_Namespace::My_System::SystemPoller_1', namespace: 'My_Namespace', @@ -1454,7 +1441,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { excludeData: {}, @@ -1478,10 +1464,6 @@ module.exports = { } ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1513,7 +1495,9 @@ module.exports = { enable: true, host: 'host1', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Consumer_1: { @@ -1527,7 +1511,9 @@ module.exports = { enable: true, host: 'host2', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Consumer_1: { @@ -1553,6 +1539,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System::SystemPoller_1', namespace: 'f5telemetry_default', @@ -1565,7 +1553,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -1577,10 +1564,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1611,6 +1594,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace::My_System::SystemPoller_1', namespace: 'My_Namespace', @@ -1623,7 +1608,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -1635,10 +1619,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1680,7 +1660,9 @@ module.exports = { systemPoller: [ { // should set name to SystemPoller_1 - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 }, 'My_Poller_2' ] @@ -1711,7 +1693,9 @@ module.exports = { systemPoller: [ { // should set name to SystemPoller_1 - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 }, 'My_Poller_2' ] @@ -1743,7 +1727,9 @@ module.exports = { systemPoller: [ { // should set name to SystemPoller_1 - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 }, 'My_Poller_2' ] @@ -1840,6 +1826,8 @@ module.exports = { }, { interval: 234, + workers: 2, + chunkSize: 34, enable: true, name: 'SystemPoller_1', class: 'Telemetry_System_Poller', @@ -1869,12 +1857,7 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'f5telemetry_default::My_System::SystemPoller_1' }, @@ -1882,6 +1865,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_2', namespace: 'f5telemetry_default', systemName: 'My_System', @@ -1909,17 +1894,14 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'f5telemetry_default::My_System::My_Poller_2' }, { interval: 234, + workers: 2, + chunkSize: 34, enable: true, name: 'SystemPoller_1', class: 'Telemetry_System_Poller', @@ -1949,12 +1931,7 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'My_Namespace::My_System::SystemPoller_1' }, @@ -1962,6 +1939,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_2', namespace: 'My_Namespace', systemName: 'My_System', @@ -1989,17 +1968,14 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'My_Namespace::My_System::My_Poller_2' }, { interval: 234, + workers: 2, + chunkSize: 34, enable: true, name: 'SystemPoller_1', class: 'Telemetry_System_Poller', @@ -2029,12 +2005,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace_2::My_System::SystemPoller_1' }, @@ -2042,6 +2013,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_2', namespace: 'My_Namespace_2', systemName: 'My_System', @@ -2069,12 +2042,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace_2::My_System::My_Poller_2' }, @@ -2082,6 +2050,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_1', namespace: 'f5telemetry_default', systemName: 'My_Poller_1', @@ -2109,12 +2079,7 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'f5telemetry_default::My_Poller_1::My_Poller_1' }, @@ -2122,6 +2087,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_1', namespace: 'My_Namespace', systemName: 'My_Poller_1', @@ -2149,12 +2116,7 @@ module.exports = { enable: true } ], - noTMStats: false, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: false }, id: 'My_Namespace::My_Poller_1::My_Poller_1' }, @@ -2162,6 +2124,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_1', namespace: 'My_Namespace_2', systemName: 'My_Poller_1', @@ -2189,12 +2153,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true }, id: 'My_Namespace_2::My_Poller_1::My_Poller_1' } @@ -2209,7 +2168,9 @@ module.exports = { class: 'Telemetry_System', enable: true, systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Namespace: { @@ -2218,7 +2179,9 @@ module.exports = { class: 'Telemetry_System', enable: true, systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } } } @@ -2237,6 +2200,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System::SystemPoller_1', namespace: 'f5telemetry_default', @@ -2249,7 +2214,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2261,10 +2225,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2278,6 +2238,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace::My_System::SystemPoller_1', namespace: 'My_Namespace', @@ -2290,7 +2252,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2302,10 +2263,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } } ] @@ -2320,7 +2277,9 @@ module.exports = { enable: true, host: 'host1', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_System_2: { @@ -2328,7 +2287,9 @@ module.exports = { enable: true, host: 'host2', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Consumer_1: { @@ -2346,7 +2307,9 @@ module.exports = { enable: true, host: 'host3', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_System_2: { @@ -2354,7 +2317,9 @@ module.exports = { enable: true, host: 'host4', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Consumer_1: { @@ -2373,7 +2338,9 @@ module.exports = { enable: true, host: 'host5', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_System_2: { @@ -2381,7 +2348,9 @@ module.exports = { enable: true, host: 'host6', systemPoller: { - interval: 234 + interval: 234, + workers: 2, + chunkSize: 34 } }, My_Consumer_1: { @@ -2415,6 +2384,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System::SystemPoller_1', namespace: 'f5telemetry_default', @@ -2427,7 +2398,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2439,10 +2409,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2456,6 +2422,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System_2::SystemPoller_1', namespace: 'f5telemetry_default', @@ -2468,7 +2436,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2480,10 +2447,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2531,6 +2494,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace::My_System::SystemPoller_1', namespace: 'My_Namespace', @@ -2543,7 +2508,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2555,10 +2519,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2572,6 +2532,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace::My_System_2::SystemPoller_1', namespace: 'My_Namespace', @@ -2584,7 +2546,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2596,10 +2557,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2647,6 +2604,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace_2::My_System::SystemPoller_1', namespace: 'My_Namespace_2', @@ -2659,7 +2618,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2671,10 +2629,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -2688,6 +2642,8 @@ module.exports = { type: 'output' }, interval: 234, + workers: 2, + chunkSize: 34, name: 'SystemPoller_1', id: 'My_Namespace_2::My_System_2::SystemPoller_1', namespace: 'My_Namespace_2', @@ -2700,7 +2656,6 @@ module.exports = { allowSelfSignedCert: false }, dataOpts: { - tags: undefined, actions: [ { enable: true, @@ -2712,10 +2667,6 @@ module.exports = { ], noTMStats: true - }, - credentials: { - username: undefined, - passphrase: undefined } }, { diff --git a/test/unit/data/configUtilTests/normalizeDeclarationSystemTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationSystemTestsData.js index b6a233a6..313502a6 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationSystemTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationSystemTestsData.js @@ -48,18 +48,23 @@ module.exports = { My_System_2: { class: 'Telemetry_System', host: 'host3', + username: 'test_username_1', systemPoller: [ 'SystemPoller_2', { // should assign name SystemPoller_1 - interval: 555 + interval: 555, + workers: 2, + chunkSize: 25 } ] }, SystemPoller_2: { class: 'Telemetry_System_Poller', host: 'host4', - interval: 432 + interval: 432, + workers: 7, + chunkSize: 10 }, My_Consumer_1: { class: 'Telemetry_Consumer', @@ -90,6 +95,8 @@ module.exports = { namespace: 'f5telemetry_default', enable: true, interval: 300, + workers: 5, + chunkSize: 30, dataOpts: { actions: [ { @@ -100,13 +107,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host2', @@ -130,6 +132,8 @@ module.exports = { namespace: 'f5telemetry_default', enable: true, interval: 180, + workers: 5, + chunkSize: 30, dataOpts: { actions: [ { @@ -140,13 +144,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host2', @@ -163,6 +162,8 @@ module.exports = { namespace: 'f5telemetry_default', enable: true, interval: 432, + workers: 7, + chunkSize: 10, trace: { enable: false, encoding: 'utf8', @@ -180,18 +181,16 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - username: undefined, - passphrase: undefined - }, connection: { allowSelfSignedCert: false, host: 'host3', port: 8100, protocol: 'http' + }, + credentials: { + username: 'test_username_1' } }, { @@ -210,6 +209,8 @@ module.exports = { namespace: 'f5telemetry_default', enable: true, interval: 555, + workers: 2, + chunkSize: 25, dataOpts: { actions: [ { @@ -220,18 +221,16 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host3', port: 8100, protocol: 'http' + }, + credentials: { + username: 'test_username_1' } }, { @@ -284,7 +283,13 @@ module.exports = { trace: false, host: 'host1', systemPoller: { - interval: 300 + interval: 300, + workers: 2, + chunkSize: 50 + }, + username: 'test_username_1', + passphrase: { + cipherText: 'test_passphrase_1' } }, My_Consumer_1: { @@ -325,7 +330,9 @@ module.exports = { trace: false, host: 'host5', systemPoller: { - interval: 300 + interval: 300, + workers: 3, + chunkSize: 100 } }, My_Consumer_1: { @@ -357,6 +364,8 @@ module.exports = { systemName: 'My_System_1', traceName: 'f5telemetry_default::My_System_1::SystemPoller_1', interval: 300, + workers: 2, + chunkSize: 50, dataOpts: { actions: [ { @@ -367,18 +376,21 @@ module.exports = { } } ], - tags: undefined, noTMStats: false }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host1', port: 8100, protocol: 'http' + }, + credentials: { + username: 'test_username_1', + passphrase: { + class: 'Secret', + protected: 'SecureVault', + cipherText: '$M$test_passphrase_1' + } } }, { @@ -424,6 +436,8 @@ module.exports = { systemName: 'My_System_1', traceName: 'My_Namespace::My_System_1::SystemPoller_1', interval: 300, + workers: 5, + chunkSize: 30, dataOpts: { actions: [ { @@ -434,13 +448,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: false }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host3', @@ -491,6 +500,8 @@ module.exports = { systemName: 'My_System_1', traceName: 'My_Namespace_2::My_System_1::SystemPoller_1', interval: 300, + workers: 3, + chunkSize: 100, dataOpts: { actions: [ { @@ -501,13 +512,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - passphrase: undefined, - username: undefined - }, connection: { allowSelfSignedCert: false, host: 'host5', @@ -546,6 +552,8 @@ module.exports = { Poller2: { class: 'Telemetry_System_Poller', interval: 111, + workers: 1, + chunkSize: 11, host: 'host2' }, System_Nested_Poller_Single: { @@ -553,7 +561,9 @@ module.exports = { host: 'host3', systemPoller: { // should assign SystemPoller_1 name - interval: 222 + interval: 222, + workers: 2, + chunkSize: 22 } }, System_Ref_Poller: { @@ -567,7 +577,9 @@ module.exports = { systemPoller: [ { // should assign SystemPoller_1 name - interval: 333 + interval: 444, + workers: 4, + chunkSize: 44 }, 'Poller1', 'Poller2' @@ -586,14 +598,19 @@ module.exports = { Poller2: { class: 'Telemetry_System_Poller', host: 'host7', - interval: 111 + interval: 111, + workers: 1, + chunkSize: 11 }, System_Nested_Poller_Single: { class: 'Telemetry_System', host: 'host8', systemPoller: { // should assign SystemPoller_1 name - interval: 222 + interval: 222, + workers: 2, + chunkSize: 22 + } }, System_Ref_Poller: { @@ -607,7 +624,9 @@ module.exports = { systemPoller: [ { // should assign SystemPoller_1 name - interval: 333 + interval: 444, + workers: 4, + chunkSize: 44 }, 'Poller1', 'Poller2' @@ -640,6 +659,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 222, + workers: 2, + chunkSize: 22, trace: { enable: false, encoding: 'utf8', @@ -659,13 +680,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - username: undefined, - passphrase: undefined - }, connection: { host: 'host3', allowSelfSignedCert: false, @@ -680,6 +696,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, trace: { enable: false, encoding: 'utf8', @@ -699,13 +717,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - username: undefined, - passphrase: undefined - }, connection: { allowSelfSignedCert: false, host: 'host4', @@ -720,6 +733,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 111, + workers: 1, + chunkSize: 11, trace: { enable: false, encoding: 'utf8', @@ -739,7 +754,6 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, connection: { @@ -747,10 +761,6 @@ module.exports = { allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -759,7 +769,9 @@ module.exports = { namespace: 'f5telemetry_default', class: 'Telemetry_System_Poller', enable: true, - interval: 333, + interval: 444, + workers: 4, + chunkSize: 44, trace: { enable: false, encoding: 'utf8', @@ -779,18 +791,13 @@ module.exports = { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, connection: { host: 'host5', allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -800,6 +807,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, trace: { enable: false, encoding: 'utf8', @@ -819,7 +828,6 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, connection: { @@ -827,10 +835,6 @@ module.exports = { allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -857,6 +861,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 222, + workers: 2, + chunkSize: 22, trace: { enable: false, encoding: 'utf8', @@ -876,13 +882,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - username: undefined, - passphrase: undefined - }, connection: { host: 'host8', allowSelfSignedCert: false, @@ -897,6 +898,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, trace: { enable: false, encoding: 'utf8', @@ -916,13 +919,8 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, - credentials: { - username: undefined, - passphrase: undefined - }, connection: { allowSelfSignedCert: false, host: 'host9', @@ -937,6 +935,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 111, + workers: 1, + chunkSize: 11, trace: { enable: false, encoding: 'utf8', @@ -956,7 +956,6 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, connection: { @@ -964,10 +963,6 @@ module.exports = { allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -976,7 +971,9 @@ module.exports = { namespace: 'My_Namespace', class: 'Telemetry_System_Poller', enable: true, - interval: 333, + interval: 444, + workers: 4, + chunkSize: 44, trace: { enable: false, encoding: 'utf8', @@ -996,18 +993,13 @@ module.exports = { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, connection: { host: 'host10', allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1017,6 +1009,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, trace: { enable: false, encoding: 'utf8', @@ -1036,7 +1030,6 @@ module.exports = { } } ], - tags: undefined, noTMStats: true }, connection: { @@ -1044,10 +1037,6 @@ module.exports = { allowSelfSignedCert: false, port: 8100, protocol: 'http' - }, - credentials: { - username: undefined, - passphrase: undefined } }, { @@ -1097,6 +1086,8 @@ module.exports = { class: 'Telemetry_System_Poller', host: 'host4', interval: 333, + workers: 3, + chunkSize: 30, trace: true, actions: [ { @@ -1117,6 +1108,8 @@ module.exports = { class: 'Telemetry_System_Poller', host: 'host5', interval: 333, + workers: 3, + chunkSize: 30, trace: false, actions: [ { @@ -1162,6 +1155,8 @@ module.exports = { class: 'Telemetry_System_Poller', host: 'host9', interval: 333, + workers: 3, + chunkSize: 30, trace: true, actions: [ { @@ -1182,6 +1177,8 @@ module.exports = { class: 'Telemetry_System_Poller', host: 'host10', interval: 333, + workers: 3, + chunkSize: 30, trace: false, actions: [ { @@ -1228,6 +1225,8 @@ module.exports = { }, enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'f5telemetry_default::System_Trace_Undef_Poller_String::SystemPoller_1', name: 'SystemPoller_1', @@ -1250,17 +1249,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { class: 'Telemetry_System_Poller', interval: 333, + workers: 3, + chunkSize: 30, endpoints: { endpoint1: { path: '/endpoint1', @@ -1297,17 +1293,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { class: 'Telemetry_System_Poller', interval: 333, + workers: 3, + chunkSize: 30, endpoints: { endpoint1: { path: '/endpoint1', @@ -1344,12 +1337,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { @@ -1379,6 +1367,8 @@ module.exports = { }, enable: true, interval: 300, + workers: 5, + chunkSize: 30, class: 'Telemetry_System_Poller', id: 'My_Namespace::System_Trace_Undef_Poller_String::SystemPoller_1', name: 'SystemPoller_1', @@ -1401,17 +1391,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { class: 'Telemetry_System_Poller', interval: 333, + workers: 3, + chunkSize: 30, endpoints: { endpoint1: { path: '/endpoint1', @@ -1448,17 +1435,14 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { class: 'Telemetry_System_Poller', interval: 333, + workers: 3, + chunkSize: 30, endpoints: { endpoint1: { path: '/endpoint1', @@ -1502,12 +1486,7 @@ module.exports = { enable: true } ], - noTMStats: true, - tags: undefined - }, - credentials: { - username: undefined, - passphrase: undefined + noTMStats: true } }, { diff --git a/test/unit/data/configUtilTests/normalizeDeclarationTraceValueTestsData.js b/test/unit/data/configUtilTests/normalizeDeclarationTraceValueTestsData.js index 53e58c6a..566f72cf 100644 --- a/test/unit/data/configUtilTests/normalizeDeclarationTraceValueTestsData.js +++ b/test/unit/data/configUtilTests/normalizeDeclarationTraceValueTestsData.js @@ -97,6 +97,8 @@ function generateDeclarationAndExpectedOutput(trace) { 'My_Poller_1', { interval: 60, + workers: 6, + chunkSize: 60, trace: trace.systemPoller } ] @@ -133,10 +135,6 @@ function generateDeclarationAndExpectedOutput(trace) { port: 8100, protocol: 'http' }, - credentials: { - passphrase: undefined, - username: undefined - }, dataOpts: { actions: [ { @@ -147,12 +145,13 @@ function generateDeclarationAndExpectedOutput(trace) { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, enable: true, id: 'f5telemetry_default::My_Poller_2::My_Poller_2', interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_2', namespace: 'f5telemetry_default', systemName: 'My_Poller_2', @@ -173,10 +172,6 @@ function generateDeclarationAndExpectedOutput(trace) { port: 8100, protocol: 'http' }, - credentials: { - passphrase: undefined, - username: undefined - }, dataOpts: { actions: [ { @@ -187,12 +182,13 @@ function generateDeclarationAndExpectedOutput(trace) { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, enable: true, id: 'f5telemetry_default::My_System::My_Poller_1', interval: 300, + workers: 5, + chunkSize: 30, name: 'My_Poller_1', namespace: 'f5telemetry_default', systemName: 'My_System', @@ -213,10 +209,6 @@ function generateDeclarationAndExpectedOutput(trace) { port: 8100, protocol: 'http' }, - credentials: { - passphrase: undefined, - username: undefined - }, dataOpts: { actions: [ { @@ -227,12 +219,13 @@ function generateDeclarationAndExpectedOutput(trace) { } } ], - noTMStats: true, - tags: undefined + noTMStats: true }, enable: true, id: 'f5telemetry_default::My_System::SystemPoller_1', interval: 60, + workers: 6, + chunkSize: 60, name: 'SystemPoller_1', namespace: 'f5telemetry_default', systemName: 'My_System', @@ -266,44 +259,26 @@ function generateDeclarationAndExpectedOutput(trace) { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host3', name: 'My_System', connection: { + host: 'host3', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System::iHealthPoller_1', namespace: 'f5telemetry_default', - systemName: 'My_System', traceName: 'f5telemetry_default::My_System::iHealthPoller_1' }, { @@ -327,44 +302,26 @@ function generateDeclarationAndExpectedOutput(trace) { protected: 'SecureVault' } }, - downloadFolder: undefined, + downloadFolder: '/shared/tmp', interval: { - day: undefined, frequency: 'daily', timeWindow: { start: '23:15', end: '02:15' } - }, - proxy: { - connection: { - host: undefined, - port: undefined, - protocol: undefined, - allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined - } } }, system: { - host: 'host4', name: 'My_System_2', connection: { + host: 'host4', port: 8100, protocol: 'http', allowSelfSignedCert: false - }, - credentials: { - username: undefined, - passphrase: undefined } }, id: 'f5telemetry_default::My_System_2::My_iHealth_Poller', namespace: 'f5telemetry_default', - systemName: 'My_System_2', traceName: 'f5telemetry_default::My_System_2::My_iHealth_Poller' } ] diff --git a/test/unit/data/dataUtilTestsData.js b/test/unit/data/dataUtilTestsData.js index 4640d6dc..a6bb7b73 100644 --- a/test/unit/data/dataUtilTestsData.js +++ b/test/unit/data/dataUtilTestsData.js @@ -1077,6 +1077,17 @@ module.exports = { } ], getMatches: [ + // TEST RELATED DATA STARTS HERE + { + name: 'should convert * to .*', + data: { + system: {}, + httpProfiles: {}, + virtualServers: {} + }, + propertyCtx: '*', + expectedCtx: ['httpProfiles', 'system', 'virtualServers'] + }, // TEST RELATED DATA STARTS HERE { name: 'should return what matches the property when it is a literal string', diff --git a/test/unit/data/deviceUtilTestsData.js b/test/unit/data/deviceUtilTestsData.js index 69480d2c..81286768 100644 --- a/test/unit/data/deviceUtilTestsData.js +++ b/test/unit/data/deviceUtilTestsData.js @@ -26,91 +26,6 @@ module.exports = { incorrectData: 'Edition: Final\nProduct: BIG-IQ\nVersion: 14.1.0\n' }, encryptSecret: { - 'encrypt-14.0.0': [ - { - endpoint: '/mgmt/tm/ltm/auth/radius-server', - method: 'post', - request: { - name: `telemetry_delete_me_${encryptEncode('test')}`, - secret: 'foo', - server: 'foo' - }, - response: { - secret: 'secret' - } - }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, - { - endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, - method: 'delete' - } - ], - 'encrypt-14.1.x': [ - { - endpoint: '/mgmt/tm/ltm/auth/radius-server', - method: 'post', - request: { - name: `telemetry_delete_me_${encryptEncode('test')}`, - secret: 'foo', - server: 'foo' - }, - response: { - secret: 'secret' - } - }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.1.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, - { - endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, - method: 'delete' - }, - { - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: `-c "tmsh -a list auth radius-server telemetry_delete_me_${encryptEncode('test')} secret"` - }, - response: { - commandResult: 'auth radius-server telemetry_delete_me {\n secret secret\n}' - } - } - ], 'encrypt-15.0.0': [ { endpoint: '/mgmt/tm/ltm/auth/radius-server', @@ -124,25 +39,6 @@ module.exports = { secret: 'secret' } }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '15.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, { endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, method: 'delete' @@ -157,25 +53,6 @@ module.exports = { { endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, method: 'delete' - }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '15.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } } ], encrypt1kSecret: [ @@ -189,25 +66,6 @@ module.exports = { times: 2 } }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, { endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, method: 'delete', @@ -217,25 +75,6 @@ module.exports = { } ], encrypt1kSecretWithNewLines: [ - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, { endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, method: 'delete', @@ -257,25 +96,6 @@ module.exports = { secret: 'secret,secret' } }, - { - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.0.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } - } - } - } - }, { endpoint: `/mgmt/tm/ltm/auth/radius-server/telemetry_delete_me_${encryptEncode('test')}`, method: 'delete' diff --git a/test/unit/data/endpointLoaderTestsData.js b/test/unit/data/endpointLoaderTestsData.js deleted file mode 100644 index dd5a5d45..00000000 --- a/test/unit/data/endpointLoaderTestsData.js +++ /dev/null @@ -1,657 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL - */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ - -module.exports = { - getAndExpandData: [ - { - name: 'should get data empty data', - endpointObj: { - path: '/endpoint' - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X' - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X' - } - } - }, - { - name: 'should fetch just the data', - endpointObj: { - path: '/endpoint' - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value' - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value' - } - ] - } - } - }, - { - name: 'should fetch data with stats when includeStats specified', - endpointObj: { - path: '/endpoint', - includeStats: true - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value' - } - ] - } - }, - { - endpoint: '/endpoint/object1/stats', - response: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - entries: { - 'https://localhost/endpoint/object2/stats': { - nestedStats: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - statKey: 'statValue' - } - } - } - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - kind: 'endpoint:stats', - entries: { - 'https://localhost/endpoint/object2/stats': { - nestedStats: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - statKey: 'statValue' - } - } - } - } - ] - } - } - }, - { - name: 'should expand reference', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: {} } - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' - } - } - ] - } - }, - { - endpoint: '/anotherEndpoint/refObject', - response: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ] - } - } - }, - { - name: 'should expand reference using endpointSuffix', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: { endpointSuffix: '/suffix' } } - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' - } - } - ] - } - }, - { - endpoint: '/anotherEndpoint/refObject/suffix', - response: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ] - } - } - }, - { - name: 'should expand reference and include reference\'s stats', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: { includeStats: true } } - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' - } - } - ] - } - }, - { - endpoint: '/anotherEndpoint/refObject/stats', - response: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - } - } - }, - { - endpoint: '/anotherEndpoint/refObject', - response: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - }, - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ] - } - } - }, - { - name: 'should expand reference and include reference\'s stats using endpointSuffix', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: { includeStats: true, endpointSuffix: '/suffix' } } - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' - } - } - ] - } - }, - { - endpoint: '/anotherEndpoint/refObject/stats', - response: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - } - } - }, - { - endpoint: '/anotherEndpoint/refObject/suffix', - response: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - }, - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ] - } - } - }, - { - name: 'should include stats, expand reference using suffix and include reference\'s stats', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: { includeStats: true } }, - includeStats: true - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' - } - } - ] - } - }, - { - endpoint: '/anotherEndpoint/refObject/stats', - response: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - } - } - }, - { - endpoint: '/anotherEndpoint/refObject', - response: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - }, - { - endpoint: '/endpoint/object1/stats', - response: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - entries: { - 'https://localhost/endpoint/object2/stats': { - nestedStats: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - statKey: 'statValue' - } - } - } - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - entries: { - 'https://localhost/endpoint/object2/stats': { - nestedStats: { - kind: 'endpoint:stats', - selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', - statKey: 'statValue' - } - } - }, - someRef: { - kind: 'anotherEndpoint:state', - selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', - entries: { - 'https://localhost/anotherEndpoint/refObject/stats': { - nestedStats: { - kind: 'anotherEndpoint:stats', - selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', - name: 'anotherStats', - statKey: 'statValue' - } - } - }, - someKey: 'someValue', - items: [ - { - nestedObjectName: 'name' - } - ] - } - } - ] - } - } - }, - { - name: 'should not query reference objects without link property', - endpointObj: { - path: '/endpoint', - expandReferences: { someRef: { } } - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - someRefKey: 'value' - } - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', - key: 'value', - someRef: { - someRefKey: 'value' - } - } - ] - } - } - }, - { - name: 'should not query objects without selfLink property', - endpointObj: { - path: '/endpoint', - includeStats: true - }, - endpoints: [ - { - endpoint: '/endpoint', - response: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - key: 'value', - someRef: { - someRefKey: 'value' - } - } - ] - } - } - ], - expectedData: { - name: '/endpoint', - data: { - kind: 'endpoint:state', - selfLink: 'https://localhost/endpoint?ver=X.X.X', - items: [ - { - name: 'object1', - key: 'value', - someRef: { - someRefKey: 'value' - } - } - ] - } - } - } - ] -}; diff --git a/test/unit/data/systemPollerTestsData.js b/test/unit/data/systemPollerTestsData.js index bc6c611d..e21394df 100644 --- a/test/unit/data/systemPollerTestsData.js +++ b/test/unit/data/systemPollerTestsData.js @@ -297,13 +297,17 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 321 + interval: 321, + workers: 5, + chunkSize: 30 }, The_Other_System_Poller: { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 123 + interval: 123, + workers: 5, + chunkSize: 30 } }, sysOrPollerName: 'My_System', @@ -473,13 +477,17 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 300 + interval: 300, + workers: 2, + chunkSize: 10 } }, expected: [{ class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 2, + chunkSize: 10, name: 'My_System_Poller', id: 'f5telemetry_default::My_System_Poller::My_System_Poller', namespace: 'f5telemetry_default', @@ -500,8 +508,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -527,7 +534,9 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 90 + interval: 90, + workers: 3, + chunkSize: 50 } } }, @@ -535,6 +544,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 90, + workers: 3, + chunkSize: 50, name: 'My_System_Poller', id: 'My_Namespace::My_System_Poller::My_System_Poller', namespace: 'My_Namespace', @@ -555,8 +566,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -584,13 +594,17 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 300 + interval: 300, + workers: 5, + chunkSize: 30 } }, expected: [{ class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_System_Poller', id: 'f5telemetry_default::My_System::My_System_Poller', namespace: 'f5telemetry_default', @@ -611,8 +625,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -656,7 +669,9 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 90 + interval: 90, + workers: 1, + chunkSize: 100 } } }, @@ -664,6 +679,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 90, + workers: 1, + chunkSize: 100, name: 'My_System_Poller', id: 'My_Namespace::My_System::My_System_Poller', namespace: 'My_Namespace', @@ -684,8 +701,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -720,6 +736,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'My_System_Poller', id: 'f5telemetry_default::My_System::My_System_Poller', systemName: 'My_System', @@ -740,8 +758,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -769,7 +786,9 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 300 + interval: 300, + workers: 5, + chunkSize: 30 }, My_Namespace: { class: 'Telemetry_Namespace', @@ -794,6 +813,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 90, + workers: 5, + chunkSize: 30, name: 'My_System_Poller', id: 'My_Namespace::My_System::My_System_Poller', namespace: 'My_Namespace', @@ -814,8 +835,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -853,13 +873,17 @@ module.exports = { class: 'Telemetry_System_Poller', actions: [], enable: true, - interval: 300 + interval: 300, + workers: 2, + chunkSize: 100 } }, expected: [{ class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 2, + chunkSize: 100, name: 'My_Desired_Poller', id: 'f5telemetry_default::My_System::My_Desired_Poller', systemName: 'My_System', @@ -880,8 +904,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -1006,7 +1029,9 @@ module.exports = { systemPoller: { actions: [], enable: true, - interval: 300 + interval: 300, + workers: 5, + chunkSize: 30 } } }, @@ -1014,6 +1039,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 300, + workers: 5, + chunkSize: 30, name: 'SystemPoller_1', id: 'f5telemetry_default::My_System::SystemPoller_1', systemName: 'My_System', @@ -1034,8 +1061,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, @@ -1062,7 +1088,9 @@ module.exports = { systemPoller: { actions: [], enable: true, - interval: 300 + interval: 300, + workers: 5, + chunkSize: 30 } } }, @@ -1078,7 +1106,9 @@ module.exports = { systemPoller: { actions: [], enable: true, - interval: 90 + interval: 90, + workers: 8, + chunkSize: 10 } } } @@ -1087,6 +1117,8 @@ module.exports = { class: 'Telemetry_System_Poller', enable: true, interval: 90, + workers: 8, + chunkSize: 10, name: 'SystemPoller_1', id: 'My_Namespace_Two::My_System::SystemPoller_1', systemName: 'My_System', @@ -1107,8 +1139,7 @@ module.exports = { }, dataOpts: { actions: [], - noTMStats: true, - tags: undefined + noTMStats: true }, credentials: { username: undefined, diff --git a/test/unit/actionProcessorTests.js b/test/unit/dataPipeline/actionProcessorTests.js similarity index 91% rename from test/unit/actionProcessorTests.js rename to test/unit/dataPipeline/actionProcessorTests.js index 0baaa005..54075c3d 100644 --- a/test/unit/actionProcessorTests.js +++ b/test/unit/dataPipeline/actionProcessorTests.js @@ -17,18 +17,18 @@ 'use strict'; /* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); +const moduleCache = require('../shared/restoreCache')(); const sinon = require('sinon'); -const actionProcessorData = require('./data/actionProcessorTestsData'); -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const testUtil = require('./shared/util'); +const actionProcessorData = require('../data/actionProcessorTestsData'); +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); -const actionProcessor = sourceCode('src/lib/actionProcessor'); +const actionProcessor = sourceCode('src/lib/dataPipeline/actionProcessor'); const dataUtil = sourceCode('src/lib/utils/data'); -const dataTagging = sourceCode('/src/lib/dataTagging'); +const dataTagging = sourceCode('/src/lib/dataPipeline/dataTagging'); const EVENT_TYPES = sourceCode('src/lib/constants').EVENT_TYPES; moduleCache.remember(); diff --git a/test/unit/dataPipeline/consumers/Generic_HTTP/index.js b/test/unit/dataPipeline/consumers/Generic_HTTP/index.js new file mode 100644 index 00000000..211aca18 --- /dev/null +++ b/test/unit/dataPipeline/consumers/Generic_HTTP/index.js @@ -0,0 +1,36 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Consumer API v1 + +let DATA_CTXS = []; + +module.exports = function defaultConsumer(dataCtx) { + if (arguments.length > 1) { + throw new Error('should not have more than 1 arguments'); + } + DATA_CTXS.push(dataCtx); +}; + +module.exports.reset = function () { + DATA_CTXS = []; +}; + +module.exports.getData = function () { + return DATA_CTXS; +}; diff --git a/test/unit/dataPipeline/consumers/Prometheus/index.js b/test/unit/dataPipeline/consumers/Prometheus/index.js new file mode 100644 index 00000000..e3bc2b11 --- /dev/null +++ b/test/unit/dataPipeline/consumers/Prometheus/index.js @@ -0,0 +1,116 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('assert'); + +const API = require('../../../../../src/lib/consumers/api'); + +const MODULE_INSTANCES = []; + +class PrometheusConsumer extends API.Consumer { + constructor() { + super(); + this.isActive = true; + this.dataCtxs = []; + } + + get allowsPull() { + return true; + } + + get allowsPush() { + return false; + } + + onData(dataCtx, emask, callback) { + assert.ok(this.isActive); + this.dataCtxs.push(dataCtx); + if (callback) { + callback(emask); + } + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } + + reset() { + this.dataCtxs = []; + } +} + +class PrometheusModule extends API.ConsumerModule { + constructor() { + super(); + this.isActive = true; + this.consumerInstances = []; + this.deletedInstances = []; + } + + createConsumer() { + assert.ok(this.isActive); + return this.consumerInstances[this.consumerInstances.push(new PrometheusConsumer()) - 1]; + } + + deleteConsumer(instance) { + assert.ok(this.isActive); + const idx = this.consumerInstances.findIndex((val) => val.inst === instance); + if (idx === -1) { + throw new Error('Unknown consumer instance!'); + } + this.consumerInstances.splice(idx, 1); + this.deletedInstances.push(instance); + return super.deleteConsumer(instance); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } +} + +/** + * Load Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + load() { + return MODULE_INSTANCES[MODULE_INSTANCES.push(new PrometheusModule()) - 1]; + }, + getInstances() { + return MODULE_INSTANCES; + } +}; diff --git a/test/unit/dataPipeline/consumers/Splunk/index.js b/test/unit/dataPipeline/consumers/Splunk/index.js new file mode 100644 index 00000000..f8ba6b3f --- /dev/null +++ b/test/unit/dataPipeline/consumers/Splunk/index.js @@ -0,0 +1,118 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-bitwise, no-proto */ + +'use strict'; + +const assert = require('assert'); + +const API = require('../../../../../src/lib/consumers/api'); + +const MODULE_INSTANCES = []; + +class SplunkConsumer extends API.Consumer { + constructor() { + super(); + this.isActive = true; + this.dataCtxs = []; + } + + get allowsPull() { + return false; + } + + get allowsPush() { + return true; + } + + onData(dataCtx, emask, callback) { + assert.ok(this.isActive); + this.dataCtxs.push(dataCtx); + if (callback) { + callback(emask); + } + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } + + reset() { + this.dataCtxs = []; + } +} + +class SplunkModule extends API.ConsumerModule { + constructor() { + super(); + this.consumerInstances = []; + this.deletedInstances = []; + this.isActive = true; + } + + createConsumer() { + assert.ok(this.isActive); + return this.consumerInstances[this.consumerInstances.push(new SplunkConsumer()) - 1]; + } + + deleteConsumer(instance) { + assert.ok(this.isActive); + const idx = this.consumerInstances.findIndex((val) => val.inst === instance); + if (idx === -1) { + throw new Error('Unknown consumer instance!'); + } + this.consumerInstances.splice(idx, 1); + this.deletedInstances.push(instance); + return super.deleteConsumer(instance); + } + + onLoad(config) { + assert.ok(this.isActive); + return super.onLoad(config); + } + + onUnload() { + assert.ok(this.isActive); + this.isActive = false; + return super.onUnload(); + } +} + +/** + * Load Consumer module + * + * Note: called once only if not in memory yet + * + * @param {API.ModuleConfig} moduleConfig - module's config + * + * @return {API.ConsumerModuleInterface} module instance + */ +module.exports = { + load() { + return MODULE_INSTANCES[MODULE_INSTANCES.push(new SplunkModule()) - 1]; + }, + getInstances() { + return MODULE_INSTANCES; + } +}; diff --git a/test/unit/dataPipeline/consumers/default/index.js b/test/unit/dataPipeline/consumers/default/index.js new file mode 100644 index 00000000..211aca18 --- /dev/null +++ b/test/unit/dataPipeline/consumers/default/index.js @@ -0,0 +1,36 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Consumer API v1 + +let DATA_CTXS = []; + +module.exports = function defaultConsumer(dataCtx) { + if (arguments.length > 1) { + throw new Error('should not have more than 1 arguments'); + } + DATA_CTXS.push(dataCtx); +}; + +module.exports.reset = function () { + DATA_CTXS = []; +}; + +module.exports.getData = function () { + return DATA_CTXS; +}; diff --git a/test/unit/dataFilterTests.js b/test/unit/dataPipeline/dataFilterTests.js similarity index 74% rename from test/unit/dataFilterTests.js rename to test/unit/dataPipeline/dataFilterTests.js index 7cd37571..6f660da3 100644 --- a/test/unit/dataFilterTests.js +++ b/test/unit/dataPipeline/dataFilterTests.js @@ -17,12 +17,12 @@ 'use strict'; /* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); +const moduleCache = require('../shared/restoreCache')(); -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); -const dataFilter = sourceCode('src/lib/dataFilter'); +const DataFilter = sourceCode('src/lib/dataPipeline/dataFilter'); moduleCache.remember(); @@ -42,11 +42,10 @@ describe('Data Filter', () => { } }; - const expected = { tmstats: true }; - const filter = new dataFilter.DataFilter(consumerConfig); + const filter = new DataFilter(consumerConfig); const filteredData = filter.apply(data); - assert.deepStrictEqual(filter.excludeList, expected); + assert.deepStrictEqual(filter.excludeList, { tmstats: true }); assert.deepStrictEqual(filteredData, { data: {} }); }); @@ -60,11 +59,10 @@ describe('Data Filter', () => { tmstats: {} } }; - const expected = {}; - const filter = new dataFilter.DataFilter(consumerConfig); + const filter = new DataFilter(consumerConfig); const filteredData = filter.apply(data); - assert.deepStrictEqual(filter.excludeList, expected); + assert.isNull(filter.excludeList); assert.deepStrictEqual(filteredData, data); }); }); diff --git a/test/unit/dataTaggingTests.js b/test/unit/dataPipeline/dataTaggingTests.js similarity index 94% rename from test/unit/dataTaggingTests.js rename to test/unit/dataPipeline/dataTaggingTests.js index 14b00c5d..f059ad22 100644 --- a/test/unit/dataTaggingTests.js +++ b/test/unit/dataPipeline/dataTaggingTests.js @@ -17,12 +17,12 @@ 'use strict'; /* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); +const moduleCache = require('../shared/restoreCache')(); -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); -const dataTagging = sourceCode('src/lib/dataTagging'); +const dataTagging = sourceCode('src/lib/dataPipeline/dataTagging'); moduleCache.remember(); diff --git a/test/unit/dataPipeline/serviceTests.js b/test/unit/dataPipeline/serviceTests.js new file mode 100644 index 00000000..9d92c4a3 --- /dev/null +++ b/test/unit/dataPipeline/serviceTests.js @@ -0,0 +1,1104 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable global-require, import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const pathUtil = require('path'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dataPipelineUtils = require('./utils'); +const dummies = require('../shared/dummies'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); + +const configUtil = sourceCode('src/lib/utils/config'); +const constants = sourceCode('src/lib/constants'); +const ConsumersService = sourceCode('src/lib/consumers'); +const DataPipeline = sourceCode('src/lib/dataPipeline'); +const ResourceMonitor = sourceCode('src/lib/resourceMonitor'); + +const EVENT_TYPES = constants.EVENT_TYPES; +const PUSH_EVENT = 2; + +moduleCache.remember(); + +describe('Data Pipeline / Service', () => { + let appEvents; + let configWorker; + let consumers; + let coreStub; + let currentConfig; + let dataPipeline; + let dataPipelineStats; + let resMon; + + function getConsumerID(name, namespace) { + const configs = configUtil.getTelemetryConsumers(currentConfig, namespace); + configs.push(...configUtil.getTelemetryPullConsumers(currentConfig, namespace)); + + const consumerConfig = configs.find((conf) => conf.name === name); + assert.isDefined(consumerConfig); + return consumerConfig.id; + } + + function processDeclaration(declaration) { + return Promise.all([ + appEvents.waitFor('datapipeline.config.done'), + configWorker.processDeclaration(declaration) + ]); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + coreStub.utilMisc.generateUuid.numbersOnly = false; + + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + dataPipeline = new DataPipeline(); + dataPipeline.initialize(appEvents); + + appEvents.on('config.change', (config) => { + currentConfig = config; + }); + + appEvents.on('datapipeline.config.done', (stats) => { + dataPipelineStats = stats; + }); + + await dataPipeline.start(); + await coreStub.startServices(); + + assert.isTrue(dataPipeline.isRunning()); + }); + + afterEach(async () => { + await Promise.all([ + consumers ? consumers.destroy() : Promise.resolve(), + dataPipeline.destroy(), + resMon ? resMon.destroy() : Promise.resolve() + ]); + await coreStub.destroyServices(); + + sinon.restore(); + }); + + it('should enable processing by default', () => { + assert.isTrue(dataPipeline.processingEnabled); + }); + + it('should do nothing when no consumers defined', () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }) + ) + .then((dataCtx) => { + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [] + }); + })); + + it('should write JSON data to tracer', () => { + const tracer = { + write: sinon.spy() + }; + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }), + PUSH_EVENT, + null, + { tracer } + ) + .then((dataCtx) => { + assert.deepStrictEqual(tracer.write.callCount, 1, 'should write to tracer only once'); + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [] + }); + assert.deepStrictEqual(tracer.write.firstCall.args[0], dataCtx); + }); + }); + + it('should not set telemetryEventCategory if exists already', () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true, telemetryEventCategory: 'test' }) + ) + .then((dataCtx) => { + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: 'test' + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [] + }); + })); + + [ + EVENT_TYPES.IHEALTH_POLLER, + EVENT_TYPES.RAW_EVENT + ].forEach((etype) => it(`should ignore actions for "${etype}" event`, () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: { foo: 'bar' } }, etype), + PUSH_EVENT, + null, + { + actions: [ + { + enable: true, + setTag: { tag: 'value' } + } + ] + } + ) + .then((dataCtx) => { + assert.deepStrictEqual(dataCtx, { + data: { + data: { foo: 'bar' }, + telemetryEventCategory: etype + }, + type: etype, + destinationIds: [] + }); + }))); + + it('should apply actions to events data', () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: { foo: 'bar' } }), + PUSH_EVENT, + null, + { + actions: [ + { + enable: true, + setTag: { tag: 'value' } + } + ] + } + ) + .then((dataCtx) => { + assert.deepStrictEqual(dataCtx, { + data: { + data: { foo: 'bar' }, + tag: 'value', + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [] + }); + })); + + it('should apply tagging using device context info (no taga added)', () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ + aPools: { + '/Common/ts_a_pool': { + name: '/Common/ts_a_pool', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + }, + '/Common/ts_a_pool_2': { + name: '/Common/ts_a_pool_2', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + } + } + }), + PUSH_EVENT, + null, + { + actions: [ + { + enable: true, + setTag: { tenant: '`T`', application: '`A`' } + } + ], + deviceContext: { + provisioning: { + gtm: { + level: 'none' + } + } + } + } + ) + .then((dataCtx) => { + assert.deepStrictEqual(dataCtx, { + data: { + aPools: { + '/Common/ts_a_pool': { + name: '/Common/ts_a_pool', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + }, + '/Common/ts_a_pool_2': { + name: '/Common/ts_a_pool_2', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + } + }, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [] + }); + })); + + [ + 'none', + 'nominal' + ].forEach((plevel) => it(`should apply tagging using device context info (gtm provisioning - ${plevel})`, () => dataPipeline.process( + dataPipelineUtils.makeDataCtx({ + aPools: { + '/Common/ts_a_pool': { + name: '/Common/ts_a_pool', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + }, + '/Common/app/ts_a_pool_2': { + name: '/Common/app/ts_a_pool_2', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + } + } + }, EVENT_TYPES.SYSTEM_POLLER), + PUSH_EVENT, + null, + { + actions: [ + { + enable: true, + setTag: { tenant: '`T`', application: '`A`' } + } + ], + deviceContext: { + bashDisabled: true, + provisioning: { + gtm: { + level: plevel + } + } + } + } + ) + .then((dataCtx) => { + const aPools = { + '/Common/ts_a_pool': { + name: '/Common/ts_a_pool', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + }, + '/Common/app/ts_a_pool_2': { + name: '/Common/app/ts_a_pool_2', + partition: 'Common', + alternateMode: 'round-robin', + dynamicRatio: 'disabled' + } + }; + if (plevel === 'nominal') { + aPools['/Common/ts_a_pool'].tenant = 'Common'; + aPools['/Common/app/ts_a_pool_2'].tenant = 'Common'; + aPools['/Common/app/ts_a_pool_2'].application = 'app'; + } + assert.deepStrictEqual(dataCtx, { + data: { + aPools, + telemetryEventCategory: EVENT_TYPES.SYSTEM_POLLER + }, + type: EVENT_TYPES.SYSTEM_POLLER, + destinationIds: [] + }); + }))); + + describe('Data Forwarding', () => { + beforeEach(() => { + consumers = new ConsumersService(pathUtil.join(__dirname, 'consumers')); + consumers.initialize(appEvents); + return consumers.start() + .then(() => { + assert.isTrue(consumers.isRunning()); + return processDeclaration(dummies.declaration.base.decrypted({})); + }) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 0 }); + }); + }); + + it('should not forward data when not destinationIds set', () => processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({ trace: true }), + defaultConsumer3: dummies.declaration.consumer.default.decrypted({ enable: false }) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 2 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, []) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 0); + })); + + it('should not forward data when no data to forward', () => processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({ trace: true }), + defaultConsumer3: dummies.declaration.consumer.default.decrypted({ enable: false }) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 2 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('defaultConsumer2'), + getConsumerID('defaultConsumer3') + ]), + PUSH_EVENT, + null, + { + actions: [ + { + enable: true, + excludeData: {}, + locations: { + data: true, + telemetryEventCategory: true + } + } + ] + } + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 0); + })); + + it('should forward data to a consumer (API v1, push)', () => processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 1 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [getConsumerID('defaultConsumer')]) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 1); + + const dataCtx = dataCtxs[dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx.event, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer') + ] + }); + + assert.isDefined(dataCtx.config); + assert.deepStrictEqual(dataCtx.config.id, getConsumerID('defaultConsumer')); + + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + assert.isNull(dataCtx.tracer); + + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [getConsumerID('defaultConsumer')]) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 2); + + const dataCtx = dataCtxs[dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx.event, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer') + ] + }); + + assert.isDefined(dataCtx.config); + assert.deepStrictEqual(dataCtx.config.id, getConsumerID('defaultConsumer')); + + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + assert.isNull(dataCtx.tracer); + + return processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({ trace: true }) + })); + }) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 1 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data3: true }, undefined, [getConsumerID('defaultConsumer')]) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 3); + + const dataCtx = dataCtxs[dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx.event, { + data: { + data3: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer') + ] + }); + + assert.isDefined(dataCtx.config); + assert.deepStrictEqual(dataCtx.config.id, getConsumerID('defaultConsumer')); + + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + assert.isDefined(dataCtx.tracer); + })); + + it('should forward data to multiple consumers (API v1, push)', () => processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}), + defaultConsumer2: dummies.declaration.consumer.default.decrypted({ trace: true }), + defaultConsumer3: dummies.declaration.consumer.default.decrypted({ enable: false }) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 2 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('defaultConsumer2'), + getConsumerID('defaultConsumer3') + ]) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 2); + + dataCtxs.slice(0, 2).forEach((dataCtx) => { + assert.deepStrictEqual(dataCtx.event, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('defaultConsumer2'), + getConsumerID('defaultConsumer3') + ] + }); + assert.isDefined(dataCtx.config); + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + + if (dataCtx.config.id === getConsumerID('defaultConsumer')) { + assert.isNull(dataCtx.tracer); + } else if (dataCtx.config.id === getConsumerID('defaultConsumer2')) { + assert.isDefined(dataCtx.tracer); + } else { + assert.fail(`Unknown consumer ID - ${dataCtx.config.id}`); + } + }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('defaultConsumer2'), + getConsumerID('defaultConsumer3') + ]) + ); + }) + .then(() => { + const dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 4); + + dataCtxs.slice(2, 2).forEach((dataCtx) => { + assert.deepStrictEqual(dataCtx.event, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('defaultConsumer2'), + getConsumerID('defaultConsumer3') + ] + }); + assert.isDefined(dataCtx.config); + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + + if (dataCtx.config.id === getConsumerID('defaultConsumer')) { + assert.isNull(dataCtx.tracer); + } else if (dataCtx.config.id === getConsumerID('defaultConsumer2')) { + assert.isDefined(dataCtx.tracer); + } else { + assert.fail(`Unknown consumer ID - ${dataCtx.config.id}`); + } + }); + })); + + it('should forward data to a consumer (API v2, push)', () => processDeclaration(dummies.declaration.base.decrypted({ + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 1 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [getConsumerID('splunkConsumer')]) + ); + }) + .then(() => { + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 1); + + const dataCtx = splunk.dataCtxs[splunk.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [getConsumerID('splunkConsumer')]) + ); + }) + .then(() => { + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 2); + + const dataCtx = splunk.dataCtxs[splunk.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + })); + + it('should forward data to multiple consumers (API v2, push, no callback)', () => processDeclaration(dummies.declaration.base.decrypted({ + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer3: dummies.declaration.consumer.splunk.minimal.decrypted({ enable: false }) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 2 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ]) + ); + }) + .then(() => { + const splunk = require('./consumers/Splunk').getInstances()[0]; + splunk.consumerInstances.forEach((slpunkInst) => { + assert.lengthOf(slpunkInst.dataCtxs, 1); + + const dataCtx = slpunkInst.dataCtxs[slpunkInst.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + }); + + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ]) + ); + }) + .then(() => { + const splunk = require('./consumers/Splunk').getInstances()[0]; + splunk.consumerInstances.forEach((slpunkInst) => { + assert.lengthOf(slpunkInst.dataCtxs, 2); + + const dataCtx = slpunkInst.dataCtxs[slpunkInst.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + }); + })); + + it('should forward data to multiple consumers (API v2, push+pull, with callback)', async () => { + await processDeclaration(dummies.declaration.base.decrypted({ + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer2: dummies.declaration.consumer.splunk.minimal.decrypted({}), + splunkConsumer3: dummies.declaration.consumer.splunk.minimal.decrypted({ enable: false }) + })); + + const cb = sinon.spy(); + + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 2 }); + await dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ]), + 0b11, // push + pull + cb + ); + + assert.deepStrictEqual(cb.callCount, 2); + cb.args.forEach((args) => { + assert.deepStrictEqual(args, [0b11]); + }); + + const splunk1 = require('./consumers/Splunk').getInstances()[0]; + splunk1.consumerInstances.forEach((slpunkInst) => { + assert.lengthOf(slpunkInst.dataCtxs, 1); + + const dataCtx = slpunkInst.dataCtxs[slpunkInst.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + }); + + cb.resetHistory(); + + await dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ]), + 0b01, + cb + ); + + assert.deepStrictEqual(cb.callCount, 2); + cb.args.forEach((args) => { + assert.deepStrictEqual(args, [0b01]); + }); + + const splunk2 = require('./consumers/Splunk').getInstances()[0]; + splunk2.consumerInstances.forEach((slpunkInst) => { + assert.lengthOf(slpunkInst.dataCtxs, 2); + + const dataCtx = slpunkInst.dataCtxs[slpunkInst.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('splunkConsumer'), + getConsumerID('splunkConsumer2'), + getConsumerID('splunkConsumer3') + ] + }); + + assert.isUndefined(dataCtx.config); + assert.isUndefined(dataCtx.event); + assert.isUndefined(dataCtx.logger); + assert.isUndefined(dataCtx.metadata); + assert.isUndefined(dataCtx.tracer); + }); + }); + + it('should apply actions and copy data (API mixed)', () => processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}), + genericHttp: dummies.declaration.consumer.genericHttp.minimal.decrypted({ + actions: [ + { + JMESPath: {}, + expression: '{ message: @ }' + } + ] + }), + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + })) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 3 }); + return dataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ]) + ); + }) + .then(() => { + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 1); + + let dataCtx = splunk.dataCtxs[splunk.dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + let dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 1); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx.event, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + assert.isDefined(dataCtx.config); + assert.deepStrictEqual(dataCtx.config.id, getConsumerID('defaultConsumer')); + + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + assert.isNull(dataCtx.tracer); + + dataCtxs = require('./consumers/Generic_HTTP').getData(); + assert.lengthOf(dataCtxs, 1); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + + assert.deepStrictEqual(dataCtx.event, { + data: { + message: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + } + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + assert.isDefined(dataCtx.config); + assert.deepStrictEqual(dataCtx.config.id, getConsumerID('genericHttp')); + + assert.isDefined(dataCtx.logger); + assert.isNull(dataCtx.metadata); + assert.isNull(dataCtx.tracer); + })); + }); + + describe('Data Processing State', () => { + let clock; + + beforeEach(() => { + clock = stubs.clock(); + + consumers = new ConsumersService(pathUtil.join(__dirname, 'consumers')); + consumers.initialize(appEvents); + resMon = new ResourceMonitor(); + resMon.initialize(appEvents); + + return consumers.start() + .then(() => { + assert.isTrue(consumers.isRunning()); + return resMon.start(); + }) + .then(() => { + assert.isTrue(resMon.isRunning()); + return processDeclaration(dummies.declaration.base.decrypted({ + defaultConsumer: dummies.declaration.consumer.default.decrypted({}), + genericHttp: dummies.declaration.consumer.genericHttp.minimal.decrypted({}), + poller: dummies.declaration.systemPoller.minimal.decrypted({}), + splunkConsumer: dummies.declaration.consumer.splunk.minimal.decrypted({}) + })); + }) + .then(() => { + assert.deepStrictEqual(dataPipelineStats, { numberOfForwarders: 3 }); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 30 }); + }) + .then(() => { + assert.isTrue(dataPipeline.processingEnabled); + }); + }); + + it('should toggle processing state', () => DataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ]) + ) + .then(() => { + assert.isTrue(dataPipeline.processingEnabled); + + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 1); + + let dataCtx = splunk.dataCtxs[splunk.dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + let dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 1); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx.event, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + dataCtxs = require('./consumers/Generic_HTTP').getData(); + assert.lengthOf(dataCtxs, 1); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx.event, { + data: { + data: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + coreStub.resourceMonitorUtils.osAvailableMem.free = 10; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(dataPipeline.processingEnabled); + return DataPipeline.process( + dataPipelineUtils.makeDataCtx({ data: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ]) + ); + }) + .then(() => { + assert.isFalse(dataPipeline.processingEnabled); + + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 1); + + let dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 1); + + dataCtxs = require('./consumers/Generic_HTTP').getData(); + assert.lengthOf(dataCtxs, 1); + + coreStub.resourceMonitorUtils.osAvailableMem.free = 1000; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(dataPipeline.processingEnabled); + return DataPipeline.process( + dataPipelineUtils.makeDataCtx({ data2: true }, undefined, [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ]) + ); + }) + .then(() => { + assert.isTrue(dataPipeline.processingEnabled); + + const splunk = require('./consumers/Splunk').getInstances()[0].consumerInstances[0]; + assert.lengthOf(splunk.dataCtxs, 2); + + let dataCtx = splunk.dataCtxs[splunk.dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + let dataCtxs = require('./consumers/default').getData(); + assert.lengthOf(dataCtxs, 2); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx.event, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + + dataCtxs = require('./consumers/Generic_HTTP').getData(); + assert.lengthOf(dataCtxs, 2); + + dataCtx = dataCtxs[dataCtxs.length - 1]; + assert.deepStrictEqual(dataCtx.event, { + data: { + data2: true, + telemetryEventCategory: EVENT_TYPES.LTM_EVENT + }, + type: EVENT_TYPES.LTM_EVENT, + destinationIds: [ + getConsumerID('defaultConsumer'), + getConsumerID('genericHttp'), + getConsumerID('splunkConsumer') + ] + }); + })); + }); +}); + +// TODO: pull v2, push-pull v2 diff --git a/test/unit/dataPipeline/utils.js b/test/unit/dataPipeline/utils.js new file mode 100644 index 00000000..b4bb1b52 --- /dev/null +++ b/test/unit/dataPipeline/utils.js @@ -0,0 +1,29 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +function makeDataCtx(data, type = 'LTM', destinationIds = []) { + return { + data, + type, + destinationIds + }; +} + +module.exports = { + makeDataCtx +}; diff --git a/test/unit/dataPipelineTests.js b/test/unit/dataPipelineTests.js deleted file mode 100644 index 001fa136..00000000 --- a/test/unit/dataPipelineTests.js +++ /dev/null @@ -1,418 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); -const testUtil = require('./shared/util'); - -const actionProcessor = sourceCode('src/lib/actionProcessor'); -const configWorker = sourceCode('src/lib/config'); -const constants = sourceCode('src/lib/constants'); -const consumers = sourceCode('src/lib/consumers'); -const dataPipeline = sourceCode('src/lib/dataPipeline'); -const forwarder = sourceCode('src/lib/forwarder'); -const ResourceMonitor = sourceCode('src/lib/resourceMonitor'); - -const EVENT_TYPES = constants.EVENT_TYPES; - -moduleCache.remember(); - -describe('Data Pipeline', () => { - let forwardFlag; - let forwardedData; - let forwardError; - let processActionsData; - let processActionsStub; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - forwardedData = undefined; - forwardError = undefined; - forwardFlag = false; - processActionsData = []; - - sinon.stub(forwarder, 'forward').callsFake((data) => { - forwardFlag = true; - if (forwardError) { - return Promise.reject(forwardError); - } - forwardedData = data; - return Promise.resolve(); - }); - - processActionsStub = sinon.stub(actionProcessor, 'processActions').callsFake((dataCtx, actions, deviceCtx) => { - processActionsData.push({ dataCtx, actions, deviceCtx }); - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should process data without options', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - return dataPipeline.process(dataCtx) - .then((processedData) => { - assert.deepStrictEqual(processedData, dataCtx); - assert.deepStrictEqual(forwardedData, processedData, 'forwarded data should match processed data'); - }); - }); - - it('should not forward data when noConsumers is true', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - return dataPipeline.process(dataCtx, { noConsumers: true }) - .then((processedData) => { - assert.deepStrictEqual(processedData, dataCtx); - assert.strictEqual(forwardedData, undefined, 'should not forward data'); - }); - }); - - it('should write JSON data to tracer', () => { - let tracerData; - const tracer = { - write: (data) => { - tracerData = data; - } - }; - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - return dataPipeline.process(dataCtx, { tracer }) - .then(() => { - assert.notStrictEqual(tracerData, undefined, 'should write data to tracer'); - assert.deepStrictEqual(tracerData, dataCtx, 'tracer data should match processed data'); - }); - }); - - it('should catch forwarder error', () => { - forwardError = new Error('test error'); - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - return assert.isFulfilled(dataPipeline.process(dataCtx)); - }); - - it('should set telemetryEventCategory if undefined', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - return dataPipeline.process(dataCtx) - .then((processedData) => { - assert.strictEqual(processedData.data.telemetryEventCategory, EVENT_TYPES.DEFAULT); - assert.strictEqual(forwardedData.data.telemetryEventCategory, EVENT_TYPES.DEFAULT); - }); - }); - - it('should ignore actions for iHealth data', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.IHEALTH_POLLER - }; - const options = { - actions: [ - { - enable: true, - setTag: {} - } - ] - }; - return dataPipeline.process(dataCtx, options) - .then(() => { - assert.isEmpty(processActionsData); - }); - }); - - it('should ignore actions for raw event data', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.RAW_EVENT - }; - const options = { - actions: [ - { - enable: true, - setTag: {} - } - ] - }; - return dataPipeline.process(dataCtx, options) - .then(() => { - assert.isEmpty(processActionsData); - }); - }); - - it('should pass deviceCtx as param for dataTagging handleAction if event is systemPoller', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.SYSTEM_POLLER - }; - const options = { - actions: [ - { - enable: true, - setTag: {} - } - ], - deviceContext: { - deviceVersion: 'a.b.c.1' - } - }; - return dataPipeline.process(dataCtx, options) - .then(() => { - assert.deepStrictEqual( - processActionsData, - [ - { - dataCtx: { - data: { - foo: 'bar', - telemetryEventCategory: 'systemInfo' - }, - type: 'systemInfo' - }, - actions: [{ enable: true, setTag: {} }], - deviceCtx: { deviceVersion: 'a.b.c.1' } - } - ] - ); - }); - }); - - it('should handle actions in desired order', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - const options = { - actions: [ - { - enable: true, - setTag: {} - }, - { - enable: true, - includeData: {} - }, - { - enable: true, - setTag: {} - }, - { - enable: false, - setTag: {} - }, - { - enable: true, - excludeData: {} - } - ] - }; - return dataPipeline.process(dataCtx, options) - .then(() => { - const actualActions = processActionsData[0].actions; - assert.lengthOf(actualActions, 5); - assert.deepStrictEqual(actualActions[0].setTag, {}); - assert.deepStrictEqual(actualActions[1].includeData, {}); - assert.deepStrictEqual(actualActions[2].setTag, {}); - assert.deepStrictEqual(actualActions[3].setTag, {}); - assert.deepStrictEqual(actualActions[4].excludeData, {}); - }); - }); - - it('should not fail on unknown action', () => { - const dataCtx = { - data: { - foo: 'bar' - }, - type: EVENT_TYPES.DEFAULT - }; - const options = { - actions: [ - { - enable: true, - unknownAction: {} - } - ] - }; - - return assert.isFulfilled(dataPipeline.process(dataCtx, options), 'should not fail on unknown actions'); - }); - - it('should not forward when no data', () => { - processActionsStub.reset(); - processActionsStub.callsFake((dataCtx) => { - dataCtx.data = {}; - }); - const dataCtx = { - data: {}, - type: EVENT_TYPES.DEFAULT - }; - const options = { - actions: [ - { - enable: true, - setTag: {} - } - ] - }; - return dataPipeline.process(dataCtx, options) - .then(() => { - assert.strictEqual(forwardFlag, false, 'should not call forwarder'); - }); - }); - - describe('monitor "on check" event', () => { - let clock; - let coreStub; - let resourceMonitor; - - const defaultDeclaration = { - class: 'Telemetry', - My_System: { - class: 'Telemetry_System', - trace: true, - systemPoller: [ - { - interval: 180 - }, - { - interval: 200 - } - ] - }, - My_Poller: { - class: 'Telemetry_System_Poller', - interval: 0 - } - }; - - const dataCtx = { - data: { - event_source: 'request_logging', - event_timestamp: '2019-01-01:01:01.000Z', - telemetryEventCategory: 'LTM' - }, - type: EVENT_TYPES.LTM_EVENT, - destinationIds: [1234, 6789] - }; - - const options = { - actions: [ - { - enable: true, - setTag: {} - } - ] - }; - - beforeEach(() => { - clock = stubs.clock(); - coreStub = stubs.default.coreStub({}); - resourceMonitor = new ResourceMonitor(); - - const appCtx = { - configMgr: configWorker, - resourceMonitor - }; - - resourceMonitor.initialize(appCtx); - dataPipeline.initialize(appCtx); - - sinon.stub(consumers, 'getConsumers').returns([ - { - name: 'consumer1', - id: 1234 - }, - { - name: 'consumer2', - id: 4564 - }, - { - name: 'consumer3', - id: 6789 - } - ]); - return resourceMonitor.start() - .then(() => Promise.all([ - configWorker.processDeclaration(testUtil.deepCopy(defaultDeclaration)), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 30 }) - ])); - }); - - it('should not forward when memory thresholds reached and log info for skipped data', () => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 10; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - .then(() => dataPipeline.process(dataCtx, options)) - .then(() => { - assert.strictEqual(forwardFlag, false, 'should not call forwarder'); - assert.strictEqual(dataPipeline.isEnabled(), false, 'should disable data pipeline'); - }); - }); - - it('should re-enable when memory thresholds return to normal', () => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 10; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - .then(() => dataPipeline.process(dataCtx, options)) - .then(() => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 500; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => dataPipeline.process(dataCtx, options)) - .then(() => { - assert.strictEqual(forwardFlag, true, 'should call forwarder'); - assert.strictEqual(dataPipeline.isEnabled(), true, 'should enable data pipeline'); - }); - }); - }); -}); diff --git a/test/unit/declaration/ajvCustomKeywordsTests.js b/test/unit/declaration/ajvCustomKeywordsTests.js index 727d6abb..312f4063 100644 --- a/test/unit/declaration/ajvCustomKeywordsTests.js +++ b/test/unit/declaration/ajvCustomKeywordsTests.js @@ -24,9 +24,6 @@ const sinon = require('sinon'); const assert = require('../shared/assert'); const common = require('./common'); const declValidator = require('./common').validate; -const sourceCode = require('../shared/sourceCode'); - -const constants = sourceCode('src/lib/constants'); moduleCache.remember(); @@ -37,52 +34,16 @@ describe('Declarations -> AJV Custom Keywords', () => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); - describe('nodeSupportVersion', () => { - const data = { - class: 'Telemetry', - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'F5_Cloud', - f5csTenantId: 'a-blabla-a', - f5csSensorId: '12345', - payloadSchemaNid: 'f5', - serviceAccount: { - type: 'not_used', - projectId: 'deos-dev', - privateKeyId: '11111111111111111111111', - privateKey: { - cipherText: 'privateKeyValue' - }, - clientEmail: 'test@deos-dev.iam.gserviceaccount.com', - clientId: '1212121212121212121212', - authUri: 'https://accounts.google.com/o/oauth2/auth', - tokenUri: 'https://oauth2.googleapis.com/token', - authProviderX509CertUrl: 'https://www.googleapis.com/oauth2/v1/certs', - clientX509CertUrl: 'https://www.googleapis.com/robot/v1/metadata/x509/test%40deos-dev.iam.gserviceaccount.com' - }, - targetAudience: 'deos-ingest' - } - }; - - it('should fail because node version too low', () => { - coreStub.utilMisc.getRuntimeInfo.value(() => ({ nodeVersion: '8.6.0' })); - return assert.isRejected(declValidator(data), 'requested node version'); - }); - - it('should succeed because node version is higher then required', () => { - coreStub.utilMisc.getRuntimeInfo.value(() => ({ nodeVersion: '8.12.0' })); - return assert.isFulfilled(declValidator(data)); - }); - }); - describe('pathExists', () => { it('should fail to access directory from iHealth declaration', () => { const data = { @@ -131,11 +92,12 @@ describe('Declarations -> AJV Custom Keywords', () => { describe('f5secret', () => { it('should fail cipherText with wrong device type', () => { - coreStub.deviceUtil.getDeviceType.resolves(constants.DEVICE_TYPE.CONTAINER); + coreStub.deviceUtil.getDeviceType.resolves('container'); const data = { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { cipherText: 'mycipher' } @@ -151,6 +113,7 @@ describe('Declarations -> AJV Custom Keywords', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { cipherText: cipher, protected: 'SecureVault' @@ -164,12 +127,12 @@ describe('Declarations -> AJV Custom Keywords', () => { }); it('should base64 decode cipherText', () => { - coreStub.deviceUtil.encryptSecret.callsFake((data) => Promise.resolve(`$M$${data}`)); const cipher = 'ZjVzZWNyZXQ='; // f5secret const data = { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { cipherText: cipher, protected: 'plainBase64' @@ -187,6 +150,7 @@ describe('Declarations -> AJV Custom Keywords', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { cipherText: 'mycipher', protected: 'SecureVault' @@ -201,6 +165,7 @@ describe('Declarations -> AJV Custom Keywords', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { protected: 'SecureVault' } @@ -214,6 +179,7 @@ describe('Declarations -> AJV Custom Keywords', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { environmentVar: 'MY_ENV_SECRET' } @@ -234,6 +200,7 @@ describe('Declarations -> AJV Custom Keywords', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { environmentVar: '' } diff --git a/test/unit/declaration/baseSchemaObjectsTests.js b/test/unit/declaration/baseSchemaObjectsTests.js index 591c59b4..7cdd3192 100644 --- a/test/unit/declaration/baseSchemaObjectsTests.js +++ b/test/unit/declaration/baseSchemaObjectsTests.js @@ -28,15 +28,19 @@ const declValidator = require('./common').validate; moduleCache.remember(); describe('Declarations -> Base Schema objects', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classControlsTests.js b/test/unit/declaration/classControlsTests.js index c47eb983..b03a7268 100644 --- a/test/unit/declaration/classControlsTests.js +++ b/test/unit/declaration/classControlsTests.js @@ -27,15 +27,19 @@ const schemaValidationUtil = require('../shared/schemaValidation'); moduleCache.remember(); describe('Declarations -> Controls', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -52,7 +56,7 @@ describe('Declarations -> Controls', () => { notAllowed: ['my-log-level', 'warning'] }, defaultValueTests: { - defaultValue: 'debug' + defaultValue: 'info' } }, { @@ -173,8 +177,11 @@ describe('Declarations -> Controls', () => { defaultValue: undefined }, numberRangeTests: { - minimum: 1, - maximum: 1400 + minimum: 1 + }, + valueTests: { + invalid: 1401, + valid: 1400 } }, { @@ -190,6 +197,37 @@ describe('Declarations -> Controls', () => { ] ); + schemaValidationUtil.generateSchemaBasicTests( + (decl) => validateMinimal(decl), + { + class: 'Controls', + runtime: { + maxHeapSize: 1500 + }, + memoryMonitor: { + memoryThresholdPercent: 10 + } + }, + [ + { + property: 'memoryMonitor.provisionedMemory', + ignoreOther: true, + valueTests: { + invalid: 1501, + valid: 1401 + } + }, + { + property: 'memoryMonitor.provisionedMemory', + ignoreOther: true, + valueTests: { + invalid: 1601, + valid: 1500 + } + } + ] + ); + schemaValidationUtil.generateSchemaBasicTests( (decl) => validateMinimal(decl), { @@ -229,11 +267,18 @@ describe('Declarations -> Controls', () => { }, { property: 'runtime.maxHeapSize', + numberRangeTests: { + minimum: 1400 + } + }, + { + property: 'runtime.httpTimeout', defaultValueTests: { - defaultValue: 1400 + defaultValue: 60 }, numberRangeTests: { - minimum: 1400 + minimum: 60, + maximum: 600 } } ] diff --git a/test/unit/declaration/classTelemetryConsumerTests/awsCloudWatchTests.js b/test/unit/declaration/classTelemetryConsumerTests/awsCloudWatchTests.js index 8eac4e99..7a735f1a 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/awsCloudWatchTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/awsCloudWatchTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> AWS_CloudWatch', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/awsS3Tests.js b/test/unit/declaration/classTelemetryConsumerTests/awsS3Tests.js index c22b3730..fdc8e739 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/awsS3Tests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/awsS3Tests.js @@ -29,16 +29,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> AWS_S3', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/azureApplicationInsightsTests.js b/test/unit/declaration/classTelemetryConsumerTests/azureApplicationInsightsTests.js index b4fd777a..3c45db52 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/azureApplicationInsightsTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/azureApplicationInsightsTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Azure_Application_Insights', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/azureLogAnalyticsTests.js b/test/unit/declaration/classTelemetryConsumerTests/azureLogAnalyticsTests.js index 13be1553..71c12316 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/azureLogAnalyticsTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/azureLogAnalyticsTests.js @@ -30,19 +30,21 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Azure_Log_Analytics', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); - it('should pass minimal declaration', () => shared.validateMinimal( { type: 'Azure_Log_Analytics', diff --git a/test/unit/declaration/classTelemetryConsumerTests/dataDogTests.js b/test/unit/declaration/classTelemetryConsumerTests/dataDogTests.js index 0816d88b..fe465a0b 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/dataDogTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/dataDogTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> DataDog', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -72,8 +75,7 @@ describe('Declarations -> Telemetry_Consumer -> DataDog', () => { { name: 'keepAlive', value: true }, { name: 'keepAliveMsecs', value: 0 }, { name: 'maxSockets', value: 0 }, - { name: 'maxFreeSockets', value: 0 }, - { name: 'anotherFeatureOption', value: 'test' } + { name: 'maxFreeSockets', value: 0 } ], proxy: { host: 'localhost', @@ -100,8 +102,7 @@ describe('Declarations -> Telemetry_Consumer -> DataDog', () => { { name: 'keepAlive', value: true }, { name: 'keepAliveMsecs', value: 0 }, { name: 'maxSockets', value: 0 }, - { name: 'maxFreeSockets', value: 0 }, - { name: 'anotherFeatureOption', value: 'test' } + { name: 'maxFreeSockets', value: 0 } ], proxy: { host: 'localhost', diff --git a/test/unit/declaration/classTelemetryConsumerTests/defaultTests.js b/test/unit/declaration/classTelemetryConsumerTests/defaultTests.js index 80a48f4d..8de0e1ba 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/defaultTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/defaultTests.js @@ -28,15 +28,19 @@ const shared = require('./shared'); moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Default', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/elasticSearchTests.js b/test/unit/declaration/classTelemetryConsumerTests/elasticSearchTests.js index 2beef75e..6ab68dfe 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/elasticSearchTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/elasticSearchTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> ElasticSearch', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/f5CloudTests.js b/test/unit/declaration/classTelemetryConsumerTests/f5CloudTests.js index 42246759..b3523c43 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/f5CloudTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/f5CloudTests.js @@ -29,18 +29,20 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> F5_Cloud', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules() - .utilMisc - .getRuntimeInfo.value(() => ({ nodeVersion: '8.12.0' })); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + coreStub.utilMisc.getRuntimeInfo.value(() => ({ nodeVersion: '8.12.0' })); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/genericHttpTests.js b/test/unit/declaration/classTelemetryConsumerTests/genericHttpTests.js index 0b71c6fb..e77d352f 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/genericHttpTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/genericHttpTests.js @@ -29,16 +29,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Generic_HTTP', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -148,8 +151,7 @@ describe('Declarations -> Telemetry_Consumer -> Generic_HTTP', () => { { name: 'keepAlive', value: true }, { name: 'keepAliveMsecs', value: 0 }, { name: 'maxSockets', value: 0 }, - { name: 'maxFreeSockets', value: 0 }, - { name: 'anotherFeatureOption', value: 'test' } + { name: 'maxFreeSockets', value: 0 } ] }, { @@ -217,8 +219,7 @@ describe('Declarations -> Telemetry_Consumer -> Generic_HTTP', () => { { name: 'keepAlive', value: true }, { name: 'keepAliveMsecs', value: 0 }, { name: 'maxSockets', value: 0 }, - { name: 'maxFreeSockets', value: 0 }, - { name: 'anotherFeatureOption', value: 'test' } + { name: 'maxFreeSockets', value: 0 } ] } )); diff --git a/test/unit/declaration/classTelemetryConsumerTests/googleCloudLoggingTests.js b/test/unit/declaration/classTelemetryConsumerTests/googleCloudLoggingTests.js index 0f96bace..0fdfafe0 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/googleCloudLoggingTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/googleCloudLoggingTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Google_Cloud_Logging', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/googleCloudMonitoringTests.js b/test/unit/declaration/classTelemetryConsumerTests/googleCloudMonitoringTests.js index 24d67596..504bd8c3 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/googleCloudMonitoringTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/googleCloudMonitoringTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Google_Cloud_Monitoring', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/graphiteTests.js b/test/unit/declaration/classTelemetryConsumerTests/graphiteTests.js index b7833f02..17ebee89 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/graphiteTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/graphiteTests.js @@ -29,16 +29,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Graphite', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/kafkaTests.js b/test/unit/declaration/classTelemetryConsumerTests/kafkaTests.js index 5edbc7c6..696c8121 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/kafkaTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/kafkaTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Kafka', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -55,7 +58,9 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { topic: 'topic', authenticationProtocol: 'None', protocol: 'binaryTcpTls', - port: 9092 + port: 9092, + format: 'default', + partitionerType: 'default' } )); @@ -64,19 +69,23 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { type: 'Kafka', host: 'host', topic: 'topic', - port: 80, + port: 9094, protocol: 'binaryTcp', authenticationProtocol: 'SASL-PLAIN', username: 'username', passphrase: { cipherText: 'cipherText' - } + }, + format: 'default', + partitionerType: 'keyed', + partitionKey: 'thePartition', + customOpts: [{ name: 'requestTimeout', value: 1999 }] }, { type: 'Kafka', host: 'host', topic: 'topic', - port: 80, + port: 9094, protocol: 'binaryTcp', authenticationProtocol: 'SASL-PLAIN', username: 'username', @@ -84,7 +93,11 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { class: 'Secret', protected: 'SecureVault', cipherText: '$M$cipherText' - } + }, + format: 'default', + partitionerType: 'keyed', + partitionKey: 'thePartition', + customOpts: [{ name: 'requestTimeout', value: 1999 }] } )); @@ -117,14 +130,16 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { class: 'Secret', protected: 'SecureVault', cipherText: '$M$clientCertificate' - } + }, + format: 'default', + partitionerType: 'default' } )); it('should pass full declaration with TLS client auth', () => shared.validateFull( { type: 'Kafka', - host: 'host', + host: ['host.first', 'host.second'], topic: 'topic', protocol: 'binaryTcpTls', port: 90, @@ -137,11 +152,15 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { }, rootCertificate: { cipherText: 'rootCertificate' - } + }, + format: 'split', + partitionerType: 'keyed', + partitionKey: 'partitionId', + customOpts: [{ name: 'requestTimeout', value: 3999 }] }, { type: 'Kafka', - host: 'host', + host: ['host.first', 'host.second'], topic: 'topic', authenticationProtocol: 'TLS', protocol: 'binaryTcpTls', @@ -160,7 +179,11 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { class: 'Secret', protected: 'SecureVault', cipherText: '$M$rootCertificate' - } + }, + format: 'split', + partitionerType: 'keyed', + partitionKey: 'partitionId', + customOpts: [{ name: 'requestTimeout', value: 3999 }] } )); @@ -203,7 +226,7 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { basicSchemaTestsValidator, { type: 'Kafka', - host: 'host', + host: ['first.host', 'second.host'], topic: 'topic', authenticationProtocol: 'SASL-PLAIN', username: 'username' @@ -232,4 +255,73 @@ describe('Declarations -> Telemetry_Consumer -> Kafka', () => { { requiredTests: true } ); }); + + describe('multiple hosts with non-default options', () => { + schemaValidationUtil.generateSchemaBasicTests( + basicSchemaTestsValidator, + { + type: 'Kafka', + host: ['first.host', 'second.host'], + topic: 'topic', + authenticationProtocol: 'SASL-PLAIN', + username: 'username', + passphrase: { + cipherText: 'cipherText' + }, + format: 'split', + partitionerType: 'random', + customOpts: [{ name: 'maxAsyncRequests', value: 14 }] + }, + [ + { property: 'host', requiredTests: true, arrayLengthTests: true }, + { property: 'customOpts', optionalPropTests: true }, + { + property: 'format', + defaultValueTests: 'default', + enumTests: { allowed: ['default', 'split'], notAllowed: ['anything-goes'] } + }, + { + property: 'partitionerType', + defaultValueTests: 'default', + enumTests: { allowed: ['default', 'random', 'cyclic'], notAllowed: ['customThatMustBeDefined'] } + } + ] + ); + }); + + describe('partitionerType == keyed', () => { + schemaValidationUtil.generateSchemaBasicTests( + basicSchemaTestsValidator, + { + type: 'Kafka', + host: ['first.host'], + topic: 'topic-on-keyed-partitions', + authenticationProtocol: 'SASL-PLAIN', + username: 'username', + passphrase: { + cipherText: 'cipherText' + }, + format: 'split', + partitionerType: 'keyed', + partitionKey: 'partition-id' + }, + 'partitionKey', + { requiredTests: true, stringLengthTests: true } + ); + }); + + describe('partitionerType != keyed', () => { + const nonKeyedTypes = ['default', 'random', 'cyclic']; + nonKeyedTypes.forEach((type) => { + it(`should not allow partitionKey if partitionerType == ${type}`, () => assert.isRejected(shared.validateFull( + { + type: 'Kafka', + host: 'host', + topic: 'topic', + partitionerType: type, + partitionKey: 'myKey' + } + ), /should NOT be valid/)); + }); + }); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/openTelemetryTests.js b/test/unit/declaration/classTelemetryConsumerTests/openTelemetryTests.js index ed0d15b7..044147af 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/openTelemetryTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/openTelemetryTests.js @@ -30,18 +30,20 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> OpenTelemetry_Exporter', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules() - .utilMisc - .getRuntimeInfo.value(() => ({ nodeVersion: '8.12.0' })); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + coreStub.utilMisc.getRuntimeInfo.value(() => ({ nodeVersion: '8.12.0' })); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/splunkTests.js b/test/unit/declaration/classTelemetryConsumerTests/splunkTests.js index 3dc71055..fd4e6588 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/splunkTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/splunkTests.js @@ -29,16 +29,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Splunk', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -124,14 +127,64 @@ describe('Declarations -> Telemetry_Consumer -> Splunk', () => { passphrase: { cipherText: 'cipherText' }, - compressionType: 'none' + compressionType: 'none', + customOpts: [ + { name: 'keepAlive', value: true }, + { name: 'keepAliveMsecs', value: 0 }, + { name: 'maxSockets', value: 0 }, + { name: 'maxFreeSockets', value: 0 } + ] }, - { - property: 'compressionType', - enumTests: { - allowed: ['none', 'gzip'], - notAllowed: ['compressionType'] + [ + { + property: 'compressionType', + enumTests: { + allowed: ['none', 'gzip'], + notAllowed: ['compressionType'] + } + }, + { + property: 'customOpts', + ignoreOther: true, + arrayLengthTests: { + minItems: 1 + } + }, + { + property: 'customOpts.0.value', + ignoreOther: true, + booleanTests: true + }, + { + property: 'customOpts.1.value', + ignoreOther: true, + numberRangeTests: { + minimum: 0 + }, + valueTests: { + invalid: 'invalid' + } + }, + { + property: 'customOpts.2.value', + ignoreOther: true, + numberRangeTests: { + minimum: 0 + }, + valueTests: { + invalid: 'invalid' + } + }, + { + property: 'customOpts.3.value', + ignoreOther: true, + numberRangeTests: { + minimum: 0 + }, + valueTests: { + invalid: 'invalid' + } } - } + ] ); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/statsdTests.js b/test/unit/declaration/classTelemetryConsumerTests/statsdTests.js index 10014654..d4f20381 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/statsdTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/statsdTests.js @@ -30,16 +30,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Statsd', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryConsumerTests/sumoLogicTests.js b/test/unit/declaration/classTelemetryConsumerTests/sumoLogicTests.js index 76ae703a..50d6e36d 100644 --- a/test/unit/declaration/classTelemetryConsumerTests/sumoLogicTests.js +++ b/test/unit/declaration/classTelemetryConsumerTests/sumoLogicTests.js @@ -29,16 +29,19 @@ moduleCache.remember(); describe('Declarations -> Telemetry_Consumer -> Sumo_Logic', () => { const basicSchemaTestsValidator = (decl) => shared.validateMinimal(decl); + let coreStub; before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryEndpointsTests.js b/test/unit/declaration/classTelemetryEndpointsTests.js index d1f5ebbb..401a0786 100644 --- a/test/unit/declaration/classTelemetryEndpointsTests.js +++ b/test/unit/declaration/classTelemetryEndpointsTests.js @@ -28,15 +28,19 @@ const declValidator = require('./common').validate; moduleCache.remember(); describe('Declarations -> Telemetry_Endpoints', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryIHealthPollerTests.js b/test/unit/declaration/classTelemetryIHealthPollerTests.js index 40004c33..83e0f18f 100644 --- a/test/unit/declaration/classTelemetryIHealthPollerTests.js +++ b/test/unit/declaration/classTelemetryIHealthPollerTests.js @@ -28,15 +28,19 @@ const declValidator = require('./common').validate; moduleCache.remember(); describe('Declarations -> Telemetry_iHealth_Poller', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -204,6 +208,28 @@ describe('Declarations -> Telemetry_iHealth_Poller', () => { return assert.isFulfilled(declValidator(data)); }); + it('should allow to specify single-digit minutes', () => { + const data = { + class: 'Telemetry', + My_iHealth: { + class: 'Telemetry_iHealth_Poller', + username: 'username', + passphrase: { + cipherText: 'cipherText' + }, + interval: { + frequency: 'weekly', + day: 'Sunday', + timeWindow: { + start: '00:0', + end: '03:9' + } + } + } + }; + return assert.isFulfilled(declValidator(data)); + }); + it('should not allow additional properties', () => { const data = { class: 'Telemetry', diff --git a/test/unit/declaration/classTelemetryListenerTests.js b/test/unit/declaration/classTelemetryListenerTests.js index 7b99ab65..a4da05a6 100644 --- a/test/unit/declaration/classTelemetryListenerTests.js +++ b/test/unit/declaration/classTelemetryListenerTests.js @@ -29,15 +29,19 @@ const generateInputActionsTests = require('./generators/inputDataActions'); moduleCache.remember(); describe('Declarations -> Telemetry_Listener', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryNamespaceTests.js b/test/unit/declaration/classTelemetryNamespaceTests.js index 534283aa..e5b73afb 100644 --- a/test/unit/declaration/classTelemetryNamespaceTests.js +++ b/test/unit/declaration/classTelemetryNamespaceTests.js @@ -27,15 +27,19 @@ const common = require('./common'); moduleCache.remember(); describe('Declarations -> Telemetry_Namespace', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -143,7 +147,9 @@ describe('Declarations -> Telemetry_Namespace', () => { host: 'localhost', port: 8100, protocol: 'http', - interval: 60 + interval: 60, + workers: 5, + chunkSize: 30 }, My_NS_Consumer: { class: 'Telemetry_Consumer', diff --git a/test/unit/declaration/classTelemetryPullConsumerTests/defaultTests.js b/test/unit/declaration/classTelemetryPullConsumerTests/defaultTests.js index 865381de..3230d471 100644 --- a/test/unit/declaration/classTelemetryPullConsumerTests/defaultTests.js +++ b/test/unit/declaration/classTelemetryPullConsumerTests/defaultTests.js @@ -28,15 +28,19 @@ const shared = require('./shared'); moduleCache.remember(); describe('Declarations -> Telemetry_Pull_Consumer -> Default', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetryPullConsumerTests/prometheusTests.js b/test/unit/declaration/classTelemetryPullConsumerTests/prometheusTests.js index c904fab3..cbc2b7c6 100644 --- a/test/unit/declaration/classTelemetryPullConsumerTests/prometheusTests.js +++ b/test/unit/declaration/classTelemetryPullConsumerTests/prometheusTests.js @@ -27,15 +27,19 @@ const shared = require('./shared'); moduleCache.remember(); describe('Declarations -> Telemetry_Pull_Consumer -> Prometheus', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/classTelemetrySystemPollerTests.js b/test/unit/declaration/classTelemetrySystemPollerTests.js index 334a732b..4ef4cd18 100644 --- a/test/unit/declaration/classTelemetrySystemPollerTests.js +++ b/test/unit/declaration/classTelemetrySystemPollerTests.js @@ -29,15 +29,19 @@ const generateInputActionsTests = require('./generators/inputDataActions'); moduleCache.remember(); describe('Declarations -> Telemetry_System_Poller', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -63,6 +67,8 @@ describe('Declarations -> Telemetry_System_Poller', () => { assert.strictEqual(poller.enable, true); assert.strictEqual(poller.trace, undefined); assert.strictEqual(poller.interval, 300); + assert.strictEqual(poller.workers, 5); + assert.strictEqual(poller.chunkSize, 30); assert.deepStrictEqual(poller.actions, [{ enable: true, setTag: { tenant: '`T`', application: '`A`' } }]); assert.strictEqual(poller.actions[0].ifAllMatch, undefined); assert.strictEqual(poller.actions[0].locations, undefined); @@ -74,6 +80,7 @@ describe('Declarations -> Telemetry_System_Poller', () => { assert.strictEqual(poller.username, undefined); assert.strictEqual(poller.passphrase, undefined); assert.strictEqual(poller.endpointList, undefined); + assert.strictEqual(poller.httpAgentOpts, undefined); }); }); @@ -85,6 +92,8 @@ describe('Declarations -> Telemetry_System_Poller', () => { enable: true, trace: true, interval: 150, + workers: 2, + chunkSize: 50, tag: { tenant: '`B`', application: '`C`' @@ -181,6 +190,12 @@ describe('Declarations -> Telemetry_System_Poller', () => { protocol: 'snmp', numericalEnums: true } + ], + httpAgentOpts: [ + { name: 'keepAlive', value: true }, + { name: 'keepAliveMsecs', value: 18000 }, + { name: 'maxSockets', value: 5 }, + { name: 'maxFreeSockets', value: 3 } ] } }; @@ -252,6 +267,16 @@ describe('Declarations -> Telemetry_System_Poller', () => { } ] ); + // httpAgentOptions + assert.deepStrictEqual( + poller.httpAgentOpts, + [ + { name: 'keepAlive', value: true }, + { name: 'keepAliveMsecs', value: 18000 }, + { name: 'maxSockets', value: 5 }, + { name: 'maxFreeSockets', value: 3 } + ] + ); }); }); @@ -316,6 +341,19 @@ describe('Declarations -> Telemetry_System_Poller', () => { return assert.isRejected(declValidator(data), /someProp.*should NOT have additional properties/); }); + it('should not allow set passphrase without username', async () => { + const data = { + class: 'Telemetry', + My_Poller: { + class: 'Telemetry_System_Poller', + passphrase: { + cipherText: 'test_passphrase_1' + } + } + }; + return assert.isRejected(declValidator(data), /passphrase.*should have property username when property passphrase is present/); + }); + describe('interval', () => { it('should allow interval=0 when endpointList is specified', () => { const data = { @@ -591,4 +629,52 @@ describe('Declarations -> Telemetry_System_Poller', () => { }); }); }); + + describe('httpAgentOpts', () => { + it('should require at least one item if present', () => { + const data = { + class: 'Telemetry', + My_Poller: { + class: 'Telemetry_System_Poller', + httpAgentOpts: [] + } + }; + const errMsg = 'should NOT have fewer than 1 items'; + return assert.isRejected(declValidator(data), errMsg); + }); + + it('should not allow an unknown option name', () => { + const data = { + class: 'Telemetry', + My_Poller: { + class: 'Telemetry_System_Poller', + httpAgentOpts: [{ name: 'some_txt', value: 'anything' }] + } + }; + const errMsg = '"allowedValues":["keepAlive","keepAliveMsecs","maxSockets","maxFreeSockets"]'; + return assert.isRejected(declValidator(data), errMsg); + }); + + describe('invalid values', () => { + const invalidVals = [ + { name: 'keepAlive', type: 'boolean', value: 123 }, + { name: 'keepAliveMsecs', type: 'integer', value: { a: 'b' } }, + { name: 'maxSockets', type: 'integer', value: '23dQW' }, + { name: 'maxFreeSockets', type: 'integer', value: 1.3034 } + ]; + invalidVals.forEach((testVal) => { + it(`${testVal.name} should be ${testVal.type}`, () => { + const data = { + class: 'Telemetry', + My_Poller: { + class: 'Telemetry_System_Poller', + httpAgentOpts: [{ name: testVal.name, value: testVal.value }] + } + }; + const errMsg = `should be ${testVal.type}`; + return assert.isRejected(declValidator(data), errMsg); + }); + }); + }); + }); }); diff --git a/test/unit/declaration/classTelemetrySystemTests.js b/test/unit/declaration/classTelemetrySystemTests.js index 5d2bbf77..a8313228 100644 --- a/test/unit/declaration/classTelemetrySystemTests.js +++ b/test/unit/declaration/classTelemetrySystemTests.js @@ -29,15 +29,19 @@ const generateInputActionsTests = require('./generators/inputDataActions'); moduleCache.remember(); describe('Declarations -> Telemetry_System', () => { + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - common.stubCoreModules(); + beforeEach(async () => { + coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -46,7 +50,9 @@ describe('Declarations -> Telemetry_System', () => { My_System: { class: 'Telemetry_System', systemPoller: { - interval: 90 + interval: 90, + workers: 9, + chunkSize: 9 } } }, ['My_System', 'systemPoller'])); @@ -56,8 +62,8 @@ describe('Declarations -> Telemetry_System', () => { My_System: { class: 'Telemetry_System', systemPoller: [ - { interval: 90 }, - { interval: 100 } + { interval: 90, workers: 9, chunkSize: 90 }, + { interval: 100, workers: 1, chunkSize: 10 } ] } }, ['My_System', 'systemPoller', '0'])); @@ -155,7 +161,9 @@ describe('Declarations -> Telemetry_System', () => { } ], enable: true, - interval: 100 + interval: 100, + workers: 5, + chunkSize: 30 } ]); }); @@ -297,6 +305,22 @@ describe('Declarations -> Telemetry_System', () => { }); }); + it('should not allow set passphrase without username', async () => { + const data = { + class: 'Telemetry', + My_System: { + class: 'Telemetry_System', + passphrase: { + cipherText: 'test_passphrase_1' + }, + systemPoller: { + interval: 60 + } + } + }; + return assert.isRejected(declValidator(data), /passphrase.*should have property username when property passphrase is present/); + }); + it('should not allow to attach inline System Poller declaration with additional properties', () => { const data = { class: 'Telemetry', @@ -508,10 +532,12 @@ describe('Declarations -> Telemetry_System', () => { systemPoller: [ { interval: 1440, - trace: true + trace: true, + workers: 10 }, { - interval: 90 + interval: 90, + chunkSize: 50 } ] } @@ -529,6 +555,8 @@ describe('Declarations -> Telemetry_System', () => { }], trace: true, interval: 1440, + workers: 10, + chunkSize: 30, enable: true }, { @@ -537,6 +565,8 @@ describe('Declarations -> Telemetry_System', () => { setTag: { application: '`A`', tenant: '`T`' } }], interval: 90, + workers: 5, + chunkSize: 50, enable: true } ] @@ -556,7 +586,9 @@ describe('Declarations -> Telemetry_System', () => { }, Poller_1: { class: 'Telemetry_System_Poller', - interval: 80 + interval: 80, + workers: 5, + chunkSize: 20 }, Poller_2: { class: 'Telemetry_System_Poller', diff --git a/test/unit/declaration/common.js b/test/unit/declaration/common.js index a4911eac..6e5f5340 100644 --- a/test/unit/declaration/common.js +++ b/test/unit/declaration/common.js @@ -25,7 +25,9 @@ const sourceCode = require('../shared/sourceCode'); const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); +const srcAssert = sourceCode('src/lib/utils/assert'); const configWorker = sourceCode('src/lib/config'); +const constants = sourceCode('src/lib/constants'); // eslint-disable-next-line no-multi-assign const _module = module.exports = { @@ -66,6 +68,23 @@ const _module = module.exports = { // TODO: remove later when logger mock will be updated fileLogger.debug('Validating declaration', decl); return configWorker.processDeclaration(decl, options) + .then((ret) => { + const components = configWorker.currentConfig.components; + assert.isDefined(components); + + components.forEach((comp) => { + if (comp.class === constants.CONFIG_CLASSES.IHEALTH_POLLER_CLASS_NAME) { + srcAssert.config.ihealthPoller(comp, 'iHealth Poller Component'); + } + if (comp.class === constants.CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME) { + srcAssert.config.systemPoller(comp, 'System Poller Component'); + } + if (comp.class === constants.CONFIG_CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME) { + srcAssert.config.pullConsumerPollerGroup(comp, 'Pull Consumer System Poller Group Component'); + } + }); + return ret; + }) .catch((err) => { fileLogger.debug('Error caught on attempt to validate declaration', err); return Promise.reject(err); diff --git a/test/unit/declaration/examplesTests.js b/test/unit/declaration/examplesTests.js index b134213f..a84157ef 100644 --- a/test/unit/declaration/examplesTests.js +++ b/test/unit/declaration/examplesTests.js @@ -35,8 +35,9 @@ describe('Declarations -> Examples', () => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = common.stubCoreModules(); + await coreStub.utilMisc.fs.promise.mkdir('/example_download_folder'); // fs access modification to skip folder check const originFsAccess = fs.access; @@ -49,10 +50,12 @@ describe('Declarations -> Examples', () => { originFsAccess.apply(null, arguments); } }); - coreStub.utilMisc.getRuntimeInfo.nodeVersion = '8.12.0'; + + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); diff --git a/test/unit/declaration/validatorTests.js b/test/unit/declaration/validatorTests.js index f98fd9b1..3904afa0 100644 --- a/test/unit/declaration/validatorTests.js +++ b/test/unit/declaration/validatorTests.js @@ -24,9 +24,6 @@ const sinon = require('sinon'); const assert = require('../shared/assert'); const common = require('./common'); const declValidator = require('./common').validate; -const sourceCode = require('../shared/sourceCode'); - -const constants = sourceCode('src/lib/constants'); moduleCache.remember(); @@ -37,11 +34,13 @@ describe('Declarations -> Validator', () => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = common.stubCoreModules(); + await coreStub.startServices(); }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -104,7 +103,7 @@ describe('Declarations -> Validator', () => { }; coreStub.utilMisc.networkCheck.rejects(new Error('failed network check')); - coreStub.deviceUtil.getDeviceType.resolves(constants.DEVICE_TYPE.CONTAINER); + coreStub.deviceUtil.getDeviceType.resolves('container'); const decl1 = { class: 'Telemetry', @@ -152,6 +151,7 @@ describe('Declarations -> Validator', () => { class: 'Telemetry', My_Poller: { class: 'Telemetry_System_Poller', + username: 'test_user_1', passphrase: { cipherText: 'mycipher' } diff --git a/test/unit/declarationHistory/declarationHistoryTests.js b/test/unit/declarationHistory/declarationHistoryTests.js new file mode 100644 index 00000000..07e527b3 --- /dev/null +++ b/test/unit/declarationHistory/declarationHistoryTests.js @@ -0,0 +1,152 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const DeclarationHistory = sourceCode('src/lib/declarationHistory'); + +moduleCache.remember(); + +describe('Declaration History / Declaration History Service', () => { + const declarationTracerFile = '/var/log/restnoded/telemetryDeclarationHistory'; + + let appEvents; + let configWorker; + let coreStub; + let dhService; + + function processDeclaration(decl) { + return Promise.all([ + configWorker.processDeclaration(decl), + appEvents.waitFor('dechistory.recorded') + ]); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + coreStub.utilMisc.generateUuid.numbersOnly = false; + + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + dhService = new DeclarationHistory(); + dhService.initialize(appEvents); + + await coreStub.startServices(); + await configWorker.cleanup(); + await dhService.start(); + + assert.isTrue(dhService.isRunning()); + }); + + afterEach(async () => { + await dhService.destroy(); + await coreStub.destroyServices(); + + appEvents.stop(); + sinon.restore(); + }); + + it('should record declaration events', () => processDeclaration(dummies.declaration.base.decrypted({})) + .then(() => { + const data = coreStub.tracer.data[declarationTracerFile]; + assert.lengthOf(data, 2, 'should write 2 events'); + assert.sameDeepMembers( + data.map((d) => d.data.event), + ['config.received', 'config.validationSucceed'] + ); + assert.sameDeepMembers( + data.map((d) => d.data.data.declaration.class), + ['Telemetry', 'Telemetry'] + ); + })); + + it('should record declaration events on application start', async () => { + coreStub.storage.restWorker.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; + + await coreStub.storage.service.restart(); + await Promise.all([ + configWorker.load(), + appEvents.waitFor('dechistory.recorded') + ]); + + const data = coreStub.tracer.data[declarationTracerFile]; + assert.lengthOf(data, 4, 'should write 2 events'); + assert.sameDeepMembers( + data.map((d) => d.data.event), + ['config.received', 'config.validationFailed', 'config.received', 'config.validationSucceed'] + ); + assert.sameDeepMembers( + data.map((d) => d.data.data.declaration.class), + ['Telemetry_Test', 'Telemetry_Test', 'Telemetry', 'Telemetry'] + ); + }); + + it('should mask secrets', () => processDeclaration(dummies.declaration.base.decrypted({ + consumer: dummies.declaration.consumer.splunk.minimal.decrypted() + })) + .then(() => testUtil.sleep(50)) + .then(() => { + const data = coreStub.tracer.data[declarationTracerFile]; + assert.sameDeepMembers( + data.map((d) => d.data.data.declaration.consumer.passphrase), + ['*********', '*********'] + ); + })); + + it('should stop recording data', () => processDeclaration(dummies.declaration.base.decrypted({})) + .then(() => { + const data = coreStub.tracer.data[declarationTracerFile]; + assert.lengthOf(data, 2, 'should have events written to tracer'); + return dhService.stop(); + }) + .then(() => processDeclaration(dummies.declaration.base.decrypted({}))) + .then(() => { + const data = coreStub.tracer.data[declarationTracerFile]; + assert.lengthOf(data, 2, 'should not write new events once stopped'); + })); + + it('should log error caught on attempt to write data', () => processDeclaration(dummies.declaration.base.decrypted({})) + .then(() => { + assert.lengthOf(coreStub.tracer.data[declarationTracerFile], 2, 'should have events written to tracer'); + + coreStub.tracer.write.throws(new Error('expected error')); + return dhService.restart(); + }) + .then(() => processDeclaration(dummies.declaration.base.decrypted({}))) + .then(() => { + assert.lengthOf(coreStub.tracer.data[declarationTracerFile], 2, 'should have now events written to tracer'); + assert.includeMatch( + coreStub.logger.messages.debug, + /Unable to wirte a new declaration history entry/ + ); + })); +}); diff --git a/test/unit/endpointLoaderTests.js b/test/unit/endpointLoaderTests.js deleted file mode 100644 index c68ddfa2..00000000 --- a/test/unit/endpointLoaderTests.js +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const nock = require('nock'); -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const endpointLoaderTestsData = require('./data/endpointLoaderTestsData'); -const sourceCode = require('./shared/sourceCode'); -const testUtil = require('./shared/util'); - -const EndpointLoader = sourceCode('src/lib/endpointLoader'); -const deviceUtil = sourceCode('src/lib/utils/device'); - -moduleCache.remember(); - -describe('Endpoint Loader', () => { - let eLoader; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - eLoader = new EndpointLoader(); - }); - - afterEach(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); - sinon.restore(); - }); - - describe('constructor', () => { - it('should set defaults', () => { - assert.strictEqual(eLoader.host, 'localhost'); - assert.deepStrictEqual(eLoader.options.credentials, {}); - assert.deepStrictEqual(eLoader.options.connection, {}); - assert.strictEqual(eLoader.endpoints, null); - assert.deepStrictEqual(eLoader.cachedResponse, {}); - }); - - it('should set host when argument is string', () => { - eLoader = new EndpointLoader('10.10.0.1'); - assert.strictEqual(eLoader.host, '10.10.0.1'); - }); - - it('should set options when argument is object', () => { - eLoader = new EndpointLoader({ foo: 'bar' }); - assert.strictEqual(eLoader.options.foo, 'bar'); - assert.deepStrictEqual(eLoader.options.credentials, {}); - assert.deepStrictEqual(eLoader.options.connection, {}); - }); - }); - - describe('.setEndpoints()', () => { - it('should set endpoints', () => { - const expected = { - foo: { - name: 'foo', - body: 'bar' - }, - '/hello/world': { - path: '/hello/world', - body: 'Hello World!' - } - }; - eLoader.setEndpoints([ - { - name: 'foo', - body: 'bar' - }, - { - path: '/hello/world', - body: 'Hello World!' - } - ]); - assert.deepStrictEqual(eLoader.endpoints, expected); - }); - - it('should overwrite endpoints', () => { - const expected = { - bar: { name: 'bar' } - }; - eLoader.endpoints = { - foo: {} - }; - eLoader.setEndpoints([ - { - name: 'bar' - } - ]); - assert.deepStrictEqual(eLoader.endpoints, expected); - }); - }); - - describe('.auth()', () => { - it('should not change token if already exists and resolve', () => { - sinon.stub(deviceUtil, 'getAuthToken').resolves({ token: '12345' }); - eLoader = new EndpointLoader({ credentials: { token: '56789' } }); - return eLoader.auth() - .then(() => { - assert.strictEqual( - eLoader.options.credentials.token, - '56789', - 'Token should not have been updated' - ); - }); - }); - - it('should save the token and resolve', () => { - sinon.stub(deviceUtil, 'getAuthToken').resolves({ token: '12345' }); - return eLoader.auth() - .then(() => { - assert.strictEqual( - eLoader.options.credentials.token, - '12345', - 'Token should have been saved' - ); - }); - }); - - it('should reject with error if getAuthToken fails', () => { - sinon.stub(deviceUtil, 'getAuthToken').rejects(new Error('some error')); - return assert.isRejected(eLoader.auth(), /some error/); - }); - - it('should pass connection and credential information to getAuthToken', () => { - eLoader = new EndpointLoader({ - credentials: { - username: 'admin', - passphrase: '12345' - }, - connection: { - protocol: 'https', - port: '8443' - } - }); - - let getAuthTokenArgs; - sinon.stub(deviceUtil, 'getAuthToken').callsFake((host, username, password, options) => { - getAuthTokenArgs = { - host, username, password, options - }; - return Promise.resolve('12345'); - }); - - return eLoader.auth() - .then(() => { - assert.deepStrictEqual(getAuthTokenArgs, { - host: 'localhost', - username: 'admin', - password: '12345', - options: { - protocol: 'https', - port: '8443' - } - }); - }); - }); - }); - - describe('.replaceBodyVars()', () => { - it('should replace string in body (object)', () => { - const body = { - command: 'run', - utilCmdArgs: '-c "echo $replaceMe"' - }; - return assert.deepStrictEqual( - eLoader.replaceBodyVars(body, { '\\$replaceMe': 'Hello World' }), - { - command: 'run', - utilCmdArgs: '-c "echo Hello World"' - } - ); - }); - - it('should replace string in body (string)', () => assert.deepStrictEqual( - eLoader.replaceBodyVars('$replaceMe', { '\\$replaceMe': 'Hello World' }), - 'Hello World' - )); - }); - - describe('.getURIPath()', () => { - it('should get path from URI', () => { - assert.strictEqual( - eLoader.getURIPath('https://localhost/path/to/something?arg=value'), - '/path/to/something' - ); - }); - }); - - describe('.getData()', () => { - it('should use POST when sending body', () => { - // resolves with httOptions - sinon.stub(deviceUtil, 'makeDeviceRequest').resolvesArg(2); - return assert.becomes( - eLoader.getData('/uri', { body: 'body' }), - { - name: '/uri', - data: { - body: 'body', - method: 'POST', - credentials: { - username: undefined, - token: undefined - } - } - } - ); - }); - - it('should retry request when failed', () => { - const requestStub = sinon.stub(deviceUtil, 'makeDeviceRequest'); - requestStub.onFirstCall().rejects(new Error('some error')); - requestStub.onSecondCall().resolves(Promise.resolve({ key: 'value' })); - return eLoader.getData('/uri') - .then((data) => { - assert.ok(requestStub.calledTwice, 'should re-try request on fail'); - assert.deepStrictEqual( - data, - { - name: '/uri', - data: { key: 'value' } - } - ); - }); - }); - - it('should build url using endpointFields', () => { - // resolves with fullUri - sinon.stub(deviceUtil, 'makeDeviceRequest').resolvesArg(1); - return eLoader.getData('/uri', { endpointFields: ['field2', 'field1', 'field3'] }) - .then((data) => { - const fields = data.data.split('?')[1].split('=')[1].split(',').sort(); - assert.deepStrictEqual(fields, ['field1', 'field2', 'field3']); - }); - }); - - it('should apply typical JSON parsing by default (parseDuplicateKeys = undefined)', () => { - nock('http://localhost:8100') - .get('/dupKeysEndpoint') - .reply(200, '{"dupKey": "hello", "dupKey": "from nock", "notADup": "unique"}'); - - return eLoader.getData('/dupKeysEndpoint') - .then((data) => { - assert.deepStrictEqual(data.data, { - notADup: 'unique', - dupKey: 'from nock' - }); - }); - }); - - it('should allow conversion of dulicate JSON keys (parseDuplicateKeys = true)', () => { - nock('http://localhost:8100') - .get('/dupKeysEndpoint') - .reply(200, '{"dupKey": "hello", "dupKey": "from nock", "notADup": "unique"}'); - - return eLoader.getData('/dupKeysEndpoint', { parseDuplicateKeys: true }) - .then((data) => { - assert.deepStrictEqual(data.data, { - notADup: 'unique', - dupKey: ['hello', 'from nock'] - }); - }); - }); - }); - - describe('.loadEndpoint()', () => { - it('should error if endpoint is not defined', () => { - eLoader.endpoints = {}; - return assert.isRejected( - eLoader.loadEndpoint('badEndpoint'), - /Endpoint not defined: badEndpoint/ - ); - }); - - it('should fail when unable to get data', () => { - sinon.stub(eLoader, 'getAndExpandData').rejects(new Error('some error')); - eLoader.setEndpoints([{ name: 'path' }]); - return assert.isRejected( - eLoader.loadEndpoint('path'), - /some error/ - ); - }); - - it('should keep endpoint untouched when need to replace keys in body', () => { - sinon.stub(eLoader, 'getAndExpandData').resolvesArg(0); - const expectedEndpointObj = { - path: '/mgmt/tm/util/bash', - body: { - command: 'run', - utilCmdArgs: '-c "echo Hello World"' - } - }; - const endpoints = { - bash: { - path: '/mgmt/tm/util/bash', - body: { - command: 'run', - utilCmdArgs: '-c "echo $replaceMe"' - } - } - }; - eLoader.endpoints = testUtil.deepCopy(endpoints); - return eLoader.loadEndpoint('bash', { replaceStrings: { '\\$replaceMe': 'Hello World' } }) - .then((data) => { - assert.deepStrictEqual(data, expectedEndpointObj); - // verify that original endpoint not changed - assert.deepStrictEqual(eLoader.endpoints, endpoints); - }); - }); - - it('should reply with cached response', () => { - eLoader.endpoints = { bash: { path: '/mgmt/tm/util/bash' } }; - eLoader.cachedResponse = { bash: 'Foo Bar' }; - return eLoader.loadEndpoint('bash') - .then((data) => { - assert.deepStrictEqual( - data, - 'Foo Bar', - 'Cached response should have returned in callback' - ); - assert.deepStrictEqual( - eLoader.cachedResponse.bash, - 'Foo Bar', - 'Should not have updated cache' - ); - }); - }); - - it('should invalidate cached response if ignoreCached is set', () => { - const expected = { - name: '/mgmt/tm/util/bash', - data: 'New Data' - }; - sinon.stub(eLoader, 'getAndExpandData').resolves(expected); - - eLoader.endpoints = { - bash: { - path: '/mgmt/tm/util/bash', - ignoreCached: true - } - }; - eLoader.cachedResponse = { bash: 'Foo Bar' }; - return eLoader.loadEndpoint('bash') - .then((data) => { - assert.deepStrictEqual( - data, - expected, - 'Updated response should have returned in callback' - ); - assert.deepStrictEqual( - eLoader.cachedResponse.bash, - expected, - 'Should have updated cache' - ); - }); - }); - - it('should load updated data when cache is empty/erased', () => { - const expected = { - name: '/mgmt/tm/util/bash', - data: 'New Data' - }; - sinon.stub(eLoader, 'getAndExpandData').resolves(expected); - - eLoader.endpoints = { - bash: { - path: '/mgmt/tm/util/bash', - ignoreCached: true - } - }; - eLoader.cachedResponse = { bash: 'Foo Bar' }; - eLoader.eraseCache(); - return eLoader.loadEndpoint('bash') - .then((data) => { - assert.deepStrictEqual( - data, - expected, - 'Updated response should have returned in callback' - ); - assert.deepStrictEqual( - eLoader.cachedResponse.bash, - expected, - 'Should have updated cache' - ); - }); - }); - }); - - describe('.getAndExpandData()', () => { - const checkResponse = (endpointMock, response) => { - if (!response.kind) { - throw new Error(`Endpoint '${endpointMock.path}' has no property 'kind' in response`); - } - }; - - endpointLoaderTestsData.getAndExpandData.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => { - testUtil.mockEndpoints(testConf.endpoints, { responseChecker: checkResponse }); - return assert.becomes( - eLoader.getAndExpandData(testConf.endpointObj), - testConf.expectedData - ); - }); - }); - }); -}); diff --git a/test/unit/eventListener/dataPublisherTests.js b/test/unit/eventListener/dataPublisherTests.js index 32d7481c..443ca144 100644 --- a/test/unit/eventListener/dataPublisherTests.js +++ b/test/unit/eventListener/dataPublisherTests.js @@ -28,21 +28,29 @@ const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); const stubs = require('../shared/stubs'); -const configWorker = sourceCode('src/lib/config'); const dataPublisher = sourceCode('src/lib/eventListener/dataPublisher'); moduleCache.remember(); describe('Data Publisher', () => { + let configWorker; + let coreStub; + before(() => { moduleCache.restore(); }); - beforeEach(() => { - stubs.default.coreStub(); + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + configWorker = coreStub.configWorker.configWorker; + + await coreStub.startServices(); }); - afterEach(() => sinon.restore()); + afterEach(async () => { + await coreStub.destroyServices(); + sinon.restore(); + }); describe('.sendDataToListener', () => { let netConnectionPort; diff --git a/test/unit/eventListener/networkServiceTests.js b/test/unit/eventListener/networkServiceTests.js index 3c587f87..9af8db01 100644 --- a/test/unit/eventListener/networkServiceTests.js +++ b/test/unit/eventListener/networkServiceTests.js @@ -19,7 +19,6 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); -const EventEmitter = require('events').EventEmitter; const sinon = require('sinon'); const net = require('net'); const udp = require('dgram'); @@ -139,43 +138,13 @@ describe('Event Listener / TCP and UDP Services', () => { }); describe('TCPService', () => { - class MockSocket extends EventEmitter { - constructor() { - super(); - sinon.spy(this, 'destroy'); - } - - destroy() { - setImmediate(() => this.emit('destroyMock', this)); - } - } - class MockServer extends EventEmitter { - constructor() { - super(); - sinon.spy(this, 'close'); - sinon.spy(this, 'listen'); - } - - setInitArgs(opts) { - this.opts = opts; - } - - listen() { - setImmediate(() => this.emit('listenMock', this, Array.from(arguments))); - } - - close() { - setImmediate(() => this.emit('closeMock', this, Array.from(arguments))); - } - } - let createServerMockCb; beforeEach(() => { receiverInst = new networkService.TCPService(createDataReceiver, testPort, { address: testAddr }); sinon.stub(net, 'createServer').callsFake(function () { - const serverMock = new MockServer(); + const serverMock = new testUtil.TCPServerMock(); serverMock.setInitArgs.apply(serverMock, arguments); if (createServerMockCb) { createServerMockCb(serverMock); @@ -194,7 +163,7 @@ describe('Event Listener / TCP and UDP Services', () => { const createMockSocket = () => { socketId += 1; - const socketMock = new MockSocket(); + const socketMock = new testUtil.TCPSocketMock(); socketMock.remoteAddress = testAddr; socketMock.remotePort = testPort + socketId; socketMock.remoteFamily = 'IPV4'; @@ -409,7 +378,7 @@ describe('Event Listener / TCP and UDP Services', () => { it('should close all opened connections', () => { const sockets = []; const createMockSocket = () => { - const socketMock = new MockSocket(); + const socketMock = new testUtil.TCPSocketMock(); socketMock.remoteAddress = testAddr; socketMock.remotePort = testPort + sockets.length; socketMock.destroy = sinon.spy(() => { @@ -470,33 +439,13 @@ describe('Event Listener / TCP and UDP Services', () => { }); describe('UDPService', () => { - class MockServer extends EventEmitter { - constructor() { - super(); - sinon.spy(this, 'close'); - sinon.spy(this, 'bind'); - } - - setInitArgs(opts) { - this.opts = opts; - } - - bind() { - setImmediate(() => this.emit('bindMock', this, Array.from(arguments))); - } - - close() { - setImmediate(() => this.emit('closeMock', this, Array.from(arguments))); - } - } - let createServerMockCb; beforeEach(() => { receiverInst = new networkService.UDPService(createDataReceiver, testPort, { address: testAddr }); sinon.stub(udp, 'createSocket').callsFake(function () { - const serverMock = new MockServer(); + const serverMock = new testUtil.UDPServerMock(); serverMock.setInitArgs.apply(serverMock, arguments); if (createServerMockCb) { serverMock.on('closeMock', (inst, args) => setImmediate(() => Promise.resolve(serverMock.emit('close')).then(args[0]))); @@ -818,20 +767,6 @@ describe('Event Listener / TCP and UDP Services', () => { }); describe('DualUDPService', () => { - class MockServer extends EventEmitter { - setInitArgs(opts) { - this.opts = opts; - } - - bind() { - this.emit('bindMock', this, Array.from(arguments)); - } - - close() { - this.emit('closeMock', this, Array.from(arguments)); - } - } - let serverMocks; let onMockCreatedCallback; const getServerMock = (ipv6) => serverMocks.find((mock) => (ipv6 && mock.opts.type === 'udp6') || (!ipv6 && mock.opts.type === 'udp4')); @@ -843,7 +778,7 @@ describe('Event Listener / TCP and UDP Services', () => { ); sinon.stub(udp, 'createSocket').callsFake(function () { - const mock = new MockServer(); + const mock = new testUtil.UDPServerMock(); mock.setInitArgs.apply(mock, arguments); serverMocks.push(mock); if (onMockCreatedCallback) { diff --git a/test/unit/eventListener/parserTests.js b/test/unit/eventListener/parserTests.js index a38dfdb0..e168f007 100644 --- a/test/unit/eventListener/parserTests.js +++ b/test/unit/eventListener/parserTests.js @@ -116,7 +116,7 @@ describe('Event Listener / Parser', () => { describe('data processing', () => { const inputModes = [ - 'regular', + // 'regular', 'byHalf' ]; const featMap = { @@ -126,7 +126,7 @@ describe('Event Listener / Parser', () => { FEAT_NONE: Parser.FEAT_NONE }; const modes = [ - 'buffer', + // 'buffer', 'string' ]; @@ -172,6 +172,7 @@ describe('Event Listener / Parser', () => { callback = (chunks, hasKVPair, hasEvtCat) => { mayHaveF5EventCategory.push(hasEvtCat); mayHaveKeyValuePairs.push(hasKVPair); + assert.deepStrictEqual(chunks.length, chunks.reduce((a) => a + 1, 0), 'should not allocate more slots than actual data'); results.push(chunks.length === 1 ? chunks[0] : chunks.reduce((a, v) => a + v, '')); }; makeInput = (chunk) => [chunk, Buffer.from(chunk).length, chunk.length]; @@ -552,6 +553,32 @@ describe('Event Listener / Parser', () => { assert.deepStrictEqual(results, ['firstLine', 'secondLineIncomple="value'], 'should produce 2 chunks of data'); }); + it('should update metadata for every pointer and do not allocate more chunks than actual data size', () => { + parser = new Parser(callback, { + mode, + maxSize: 30 + }); + + parser.push(makeInput('first1')); + parser.push(makeInput('first2')); + parser.push(makeInput('first3')); + parser.push(makeInput('Line\n')); + parser.push(makeInput('ple="value')); + parser.push(makeInput('first1')); + parser.push(makeInput('first2')); + parser.push(makeInput('fi\nrst3')); + assert.isTrue(parser.process(1e6)[0]); + + parser.push(makeInput('first4')); + parser.push(makeInput('Line\n')); + assert.isFalse(parser.process(1e6, true)[0]); + assert.deepStrictEqual(results, [ + 'first1first2first3Line', + 'ple="valuefirst1first2fi', + 'rst3first4Line' + ], 'should produce 2 chunks of data'); + }); + describe('.isReady()', () => { it('should return true when data is pending', () => { assert.isFalse(parser.isReady(), 'should return false when no data'); diff --git a/test/unit/forwarderTests.js b/test/unit/forwarderTests.js deleted file mode 100644 index 625fbaf0..00000000 --- a/test/unit/forwarderTests.js +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); - -const actionProcessor = sourceCode('src/lib/actionProcessor'); -const DataFilter = sourceCode('src/lib/dataFilter').DataFilter; -const forwarder = sourceCode('src/lib/forwarder'); -const consumers = sourceCode('src/lib/consumers'); - -moduleCache.remember(); - -describe('Forwarder', () => { - const config = { - type: 'consumerType', - traceName: 'testConsumer' - }; - const type = 'dataType'; - const data = { foo: 'bar' }; - const metadata = { compute: { onlyWhenAvailable: true } }; - - before(() => { - moduleCache.restore(); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should forward to correct consumers', () => { - let actualContext; - const consumersCalled = []; - sinon.stub(consumers, 'getConsumers').returns([ - { - consumer: (context) => { - actualContext = context; - consumersCalled.push('uuid1'); - }, - id: 'uuid1', - config, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - }, - { - consumer: (context) => { - actualContext = { orig: context, modified: true }; - consumersCalled.push('uuid2'); - }, - id: 'uuid2', - config, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - }, - { - consumer: (context) => { - actualContext = context; - consumersCalled.push('uuid3'); - }, - id: 'uuid3', - config, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - } - ]); - const mockContext = { type, data, destinationIds: ['uuid1', 'uuid3'] }; - return assert.isFulfilled(forwarder.forward(mockContext) - .then(() => { - assert.deepStrictEqual(actualContext.event.data, data); - assert.deepStrictEqual(actualContext.config, config); - assert.deepStrictEqual(actualContext.metadata, metadata); - assert.deepStrictEqual(consumersCalled, ['uuid1', 'uuid3']); - })); - }); - - it('should process any defined actions', () => { - const consumersCalled = []; - const processActionsStub = sinon.stub(actionProcessor, 'processActions'); - sinon.stub(consumers, 'getConsumers').returns([ - { - consumer: () => { - consumersCalled.push('uuid1'); - }, - id: 'uuid1', - config: { - type: 'consumerType', - traceName: 'testConsumer', - actions: [{ - enable: true, - JMESPath: {}, - expression: '{ message: @ }' - }] - }, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - } - ]); - const mockContext = { type, data, destinationIds: ['uuid1'] }; - return assert.isFulfilled(forwarder.forward(mockContext) - .then(() => { - assert.strictEqual(processActionsStub.calledOnce, true, 'should be called only once'); - assert.deepStrictEqual( - processActionsStub.firstCall.args[1], - [ - { - enable: true, - JMESPath: {}, - expression: '{ message: @ }' - } - ] - ); - assert.deepStrictEqual(consumersCalled, ['uuid1'], 'should still call consumer when actions are used'); - })); - }); - - it('should still forward the data if the action processor fails', () => { - const consumersCalled = []; - const processActionsStub = sinon.stub(actionProcessor, 'processActions').throws(new Error('ERROR')); - sinon.stub(consumers, 'getConsumers').returns([ - { - consumer: () => { - consumersCalled.push('uuid1'); - }, - id: 'uuid1', - config: { - type: 'consumerType', - traceName: 'testConsumer', - actions: [{ - enable: true, - JMESPath: {}, - expression: 'badexpression' - }] - }, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - } - ]); - const mockContext = { type, data, destinationIds: ['uuid1'] }; - return assert.isFulfilled(forwarder.forward(mockContext) - .then(() => { - assert.strictEqual(processActionsStub.calledOnce, true, 'should be called only once'); - assert.deepStrictEqual( - processActionsStub.firstCall.args[1], - [ - { - enable: true, - JMESPath: {}, - expression: 'badexpression' - } - ] - ); - assert.deepStrictEqual(processActionsStub.exceptions[0].message, 'ERROR'); - assert.deepStrictEqual(consumersCalled, ['uuid1'], 'should still call consumer when actions are used'); - })); - }); - - it('should not allow consumer actions to modify another consumer\'s data', () => { - const consumerContexts = []; - const processActionsStub = sinon.stub(actionProcessor, 'processActions').callsFake((event, actions) => { - actions = actions || []; - actions.forEach((action) => { - if (action.JMESPath) { - event.data = 'modifiedData'; - } - }); - }); - sinon.stub(consumers, 'getConsumers').returns([ - { - consumer: (context) => { - consumerContexts.push({ id: 'uuid1', data: context.event.data }); - }, - id: 'uuid1', - config: { - type: 'consumerType', - traceName: 'testConsumer', - actions: [{ - enable: true, - JMESPath: {}, - expression: '{ message: @ }' - }] - }, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - }, - { - consumer: (context) => { - consumerContexts.push({ id: 'uuid2', data: context.event.data }); - }, - id: 'uuid2', - config, - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata - } - ]); - const mockContext = { type, data, destinationIds: ['uuid1', 'uuid2'] }; - return assert.isFulfilled(forwarder.forward(mockContext) - .then(() => { - assert.strictEqual(processActionsStub.calledTwice, true, 'should be called for each consumer'); - assert.deepStrictEqual(consumerContexts, [ - { id: 'uuid1', data: 'modifiedData' }, - { id: 'uuid2', data: { foo: 'bar' } } - ], 'should only modify first consumer\'s data'); - })); - }); - - it('should resolve with no consumers', () => { - sinon.stub(consumers, 'getConsumers').returns(null); - return assert.isFulfilled(forwarder.forward({ type, data, destinationIds: [] })); - }); - - it('should resolve on consumer error', () => { - sinon.stub(consumers, 'getConsumers').returns([ - { - consumer: () => { - throw new Error('foo'); - }, - config, - id: 'uuid123', - tracer: null, - filter: new DataFilter({}), - logger: {}, - metadata: {} - } - ]); - return assert.isFulfilled(forwarder.forward({ type, data, destinationIds: ['uuid123'] })); - }); -}); diff --git a/test/unit/iHealthTests.js b/test/unit/iHealthTests.js deleted file mode 100644 index e9f589b3..00000000 --- a/test/unit/iHealthTests.js +++ /dev/null @@ -1,777 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const dummies = require('./shared/dummies'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); -const testUtil = require('./shared/util'); - -const configWorker = sourceCode('src/lib/config'); -const dataPipeline = sourceCode('src/lib/dataPipeline'); -const ihealth = sourceCode('src/lib/ihealth'); -const IHealthPoller = sourceCode('src/lib/ihealthPoller'); -const ihealthUtil = sourceCode('src/lib/utils/ihealth'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); -const tracerMgr = sourceCode('src/lib/tracerManager'); - -moduleCache.remember(); - -describe('iHealth', () => { - let clockStub; - let coreStub; - let declaration; - let ihealthStub; - let preExistingConfigIDs; - let processedReports; - - const disabledAllPollers = () => Promise.all(IHealthPoller.getAll({ includeDemo: true }) - .map((poller) => IHealthPoller.disable(poller, true))) - .then((retObjs) => Promise.all(retObjs.map((obj) => obj.stopPromise))); - - const expectedConfiguration = (includeDisabled) => { - const components = [ - dummies.configuration.ihealthPoller.full.encrypted({ - name: 'iHealthPoller_1', - id: 'f5telemetry_default::System::iHealthPoller_1', - namespace: 'f5telemetry_default', - systemName: 'System', - traceName: 'f5telemetry_default::System::iHealthPoller_1', - iHealth: { - name: 'iHealthPoller_1', - credentials: { username: 'test_user_2', passphrase: { cipherText: '$M$test_passphrase_2' } }, - proxy: { connection: { host: '192.168.100.1' }, credentials: { username: 'test_user_3', passphrase: { cipherText: '$M$test_passphrase_3' } } } - }, - system: { host: '192.168.0.1', credentials: { username: 'test_user_1', passphrase: { cipherText: '$M$test_passphrase_1' } } } - }), - dummies.configuration.ihealthPoller.full.encrypted({ - name: 'iHealthPoller_1', - id: 'Namespace::System::iHealthPoller_1', - namespace: 'Namespace', - systemName: 'System', - traceName: 'Namespace::System::iHealthPoller_1', - iHealth: { - name: 'iHealthPoller_1', - credentials: { username: 'test_user_8', passphrase: { cipherText: '$M$test_passphrase_8' } }, - proxy: { connection: { host: '192.168.100.3' }, credentials: { username: 'test_user_9', passphrase: { cipherText: '$M$test_passphrase_9' } } } - }, - system: { host: '192.168.0.3', credentials: { username: 'test_user_7', passphrase: { cipherText: '$M$test_passphrase_7' } } } - }) - ]; - if (includeDisabled) { - components.push(dummies.configuration.ihealthPoller.full.encrypted({ - name: 'iHealthPoller_1', - id: 'Disabled_System::iHealthPoller_1', - namespace: 'f5telemetry_default', - systemName: 'Disabled_System', - traceName: 'Disabled_System::iHealthPoller_1', - iHealth: { - name: 'iHealthPoller_1', - credentials: { username: 'test_user_5', passphrase: { cipherText: '$M$test_passphrase_5' } }, - proxy: { connection: { host: '192.168.100.2' }, credentials: { username: 'test_user_6', passphrase: { cipherText: '$M$test_passphrase_6' } } } - }, - system: { host: '192.168.0.2', credentials: { username: 'test_user_4', passphrase: { cipherText: '$M$test_passphrase_4' } } } - })); - components.push(dummies.configuration.ihealthPoller.full.encrypted({ - name: 'iHealthPoller_1', - id: 'Namespace::Disabled_System::iHealthPoller_1', - namespace: 'Namespace', - systemName: 'Disabled_System', - traceName: 'Namespace::Disabled_System::iHealthPoller_1', - iHealth: { - name: 'iHealthPoller_1', - credentials: { username: 'test_user_11', passphrase: { cipherText: '$M$test_passphrase_11' } }, - proxy: { connection: { host: '192.168.100.4' }, credentials: { username: 'test_user_12', passphrase: { cipherText: '$M$test_passphrase_12' } } } - }, - system: { host: '192.168.0.4', credentials: { username: 'test_user_10', passphrase: { cipherText: '$M$test_passphrase_10' } } } - })); - } - return components; - }; - - const registeredTracerPaths = () => { - const paths = tracerMgr.registered().map((t) => t.path); - paths.sort(); - return paths; - }; - const toTracerPaths = (ids) => { - const tracerPaths = ids.map((id) => `Telemetry_iHealth_Poller.${id}`); - tracerPaths.sort(); - return tracerPaths; - }; - - const verifyPollersConfig = (pollers, expectedConfigs) => Promise.all(pollers.map((p) => p.getConfig())) - .then((configs) => assert.sameDeepMembers(configs, expectedConfigs, 'should match expected configuration')); - - const waitForReport = (cb) => { - processedReports = []; - sinon.stub(dataPipeline, 'process').callsFake((dataCtx, opts) => { - processedReports.push({ dataCtx, opts }); - cb(dataCtx, opts); - return Promise.resolve(); - }); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); // 30 mins. - }; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - coreStub = stubs.default.coreStub(); - coreStub.utilMisc.generateUuid.numbersOnly = false; - ihealthStub = stubs.iHealthPoller({ - ihealthUtil - }); - declaration = dummies.declaration.base.decrypted(); - declaration.System = dummies.declaration.system.full.decrypted({ - host: '192.168.0.1', - username: 'test_user_1', - passphrase: { cipherText: 'test_passphrase_1' } - }); - declaration.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_2', - passphrase: { cipherText: 'test_passphrase_2' }, - proxy: { - host: '192.168.100.1', - username: 'test_user_3', - passphrase: { cipherText: 'test_passphrase_3' } - } - }); - declaration.Disabled_System = dummies.declaration.system.full.decrypted({ - enable: false, - host: '192.168.0.2', - username: 'test_user_4', - passphrase: { cipherText: 'test_passphrase_4' } - }); - declaration.Disabled_System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_5', - passphrase: { cipherText: 'test_passphrase_5' }, - proxy: { - host: '192.168.100.2', - username: 'test_user_6', - passphrase: { cipherText: 'test_passphrase_6' } - } - }); - declaration.DefaultConsumer = dummies.declaration.consumer.default.decrypted(); - declaration.Namespace = dummies.declaration.namespace.base.decrypted(); - declaration.Namespace.System = dummies.declaration.system.full.decrypted({ - host: '192.168.0.3', - username: 'test_user_7', - passphrase: { cipherText: 'test_passphrase_7' } - }); - declaration.Namespace.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_8', - passphrase: { cipherText: 'test_passphrase_8' }, - proxy: { - host: '192.168.100.3', - username: 'test_user_9', - passphrase: { cipherText: 'test_passphrase_9' } - } - }); - declaration.Namespace.Disabled_System = dummies.declaration.system.full.decrypted({ - enable: false, - host: '192.168.0.4', - username: 'test_user_10', - passphrase: { cipherText: 'test_passphrase_10' } - }); - declaration.Namespace.Disabled_System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_11', - passphrase: { cipherText: 'test_passphrase_11' }, - proxy: { - host: '192.168.100.4', - username: 'test_user_12', - passphrase: { cipherText: 'test_passphrase_12' } - } - }); - declaration.Namespace.DefaultConsumer = dummies.declaration.consumer.default.decrypted(); - preExistingConfigIDs = [ - 'Namespace::System::iHealthPoller_1', - 'f5telemetry_default::System::iHealthPoller_1' - ]; - // slow down polling process - ihealthStub.ihealthUtil.QkviewManager.process.rejects(new Error('expected error')); - return configWorker.processDeclaration({ class: 'Telemetry' }) - .then(() => { - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have no running pollers'); - }); - }); - - afterEach(() => disabledAllPollers() - .then(() => configWorker.processDeclaration({ class: 'Telemetry' })) - .then(() => { - sinon.restore(); - // related to blocks like .catch(err => logError(err)) - assert.isEmpty(coreStub.logger.messages.error, 'should have no errors logged'); - })); - - describe('config "on change" event', () => { - beforeEach(() => { - // slow down polling process - clockStub = stubs.clock(); - return configWorker.processDeclaration(testUtil.deepCopy(declaration)) - .then(() => { - assert.sameMembers( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - return verifyPollersConfig(IHealthPoller.getAll({ includeDemo: true }), expectedConfiguration()); - }); - }); - - it('should remove orphaned data', () => { - let restStorageData = testUtil.deepCopy(coreStub.persistentStorage.savedData); - restStorageData.ihealth.nonExistingInstance = { version: 2.0, test: true }; - coreStub.persistentStorage.loadData = restStorageData; - return persistentStorage.persistentStorage.load() - .then(() => persistentStorage.persistentStorage.get('ihealth.nonExistingInstance.test')) - .then((value) => { - assert.strictEqual(value, true, 'should load data'); - return configWorker.processDeclaration(testUtil.deepCopy(declaration)); - }) - .then(() => { - restStorageData = coreStub.persistentStorage.savedData; - assert.deepStrictEqual(restStorageData.ihealth.nonExistingInstance, undefined, 'should remove data for non-existing instance'); - assert.notDeepEqual(restStorageData.ihealth['f5telemetry_default::System::iHealthPoller_1'], undefined, 'should keep data for existing instance'); - }); - }); - - it('should update existing poller(s)', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => verifyPollersConfig( - IHealthPoller.getAll({ includeDemo: true }), - expectedConfiguration().concat(expectedConfiguration()) // same configs for 'demo' - )) - .then(() => new Promise((resolve) => { - const expectedReportsNo = IHealthPoller.getAll({ includeDemo: true }).length; - const srcIDs = {}; - ihealthStub.ihealthUtil.QkviewManager.process.resolves('qkviewFile'); - waitForReport((dataCtx, opts) => { - srcIDs[`${dataCtx.sourceId}${opts.noConsumers ? ' (DEMO)' : ''}`] = ''; - if (Object.keys(srcIDs).length >= expectedReportsNo) { - dataPipeline.process.restore(); - resolve(); - } - }); - })) - // should remove all demo instances and update existing instances - .then(() => configWorker.processDeclaration(testUtil.deepCopy(declaration))) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.sameDeepMembers( - newInstances.map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - instancesBefore.forEach((inst) => { - assert.notDeepInclude(newInstances, inst, `should create new instance for ${inst.id}`); - }); - assert.sameDeepMembers( - processedReports.map((report) => { - assert.deepStrictEqual(report.dataCtx.data.system.qkviewNumber, '0000000', 'should have report data'); - return { - id: report.dataCtx.sourceId, - demo: report.opts.noConsumers, - consumers: report.dataCtx.destinationIds - }; - }), - [ - { id: 'f5telemetry_default::System::iHealthPoller_1', demo: true, consumers: ['f5telemetry_default::DefaultConsumer'] }, - { id: 'f5telemetry_default::System::iHealthPoller_1', demo: false, consumers: ['f5telemetry_default::DefaultConsumer'] }, - { id: 'Namespace::System::iHealthPoller_1', demo: true, consumers: ['Namespace::DefaultConsumer'] }, - { id: 'Namespace::System::iHealthPoller_1', demo: false, consumers: ['Namespace::DefaultConsumer'] } - ], - 'should collect report from running pollers' - ); - return verifyPollersConfig(newInstances, expectedConfiguration()); - }); - }); - - it('should start new poller without restarting existing one when processing a new namespace declaration', () => { - let instancesBefore; - let configIDsBeforeUpdate; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => verifyPollersConfig( - IHealthPoller.getAll({ includeDemo: true }), - expectedConfiguration().concat(expectedConfiguration()) - )) - .then(() => { - instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - // demo ID for default namespace only - configIDsBeforeUpdate = preExistingConfigIDs.concat(['f5telemetry_default::System::iHealthPoller_1']); - const namespaceDeclaration = testUtil.deepCopy(declaration.Namespace); - // should remove demo instance from namespace - return configWorker.processNamespaceDeclaration(namespaceDeclaration, 'Namespace'); - }) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.sameDeepMembers( - newInstances.map((p) => p.id), - configIDsBeforeUpdate, - 'should create instances with expected IDs' - ); - // using pre-existing list of IDs because 'demo' using same Tracer instance - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - // ignoring Namespace' 'demo' instance - instancesBefore = instancesBefore.filter((inst) => configIDsBeforeUpdate.indexOf(inst.id) !== -1); - instancesBefore.forEach((inst) => { - if (inst.id.startsWith('Namespace')) { - assert.notDeepInclude(newInstances, inst, 'should create new instance on update'); - } else { - assert.deepInclude(newInstances, inst, 'should not create new instance when only namespace was updated'); - } - }); - }) - .then(() => new Promise((resolve) => { - const expectedReportsNo = IHealthPoller.getAll({ includeDemo: true }).length; - const srcIDs = {}; - ihealthStub.ihealthUtil.QkviewManager.process.resolves('qkviewFile'); - waitForReport((dataCtx, opts) => { - srcIDs[`${dataCtx.sourceId}${opts.noConsumers ? ' (DEMO)' : ''}`] = ''; - if (Object.keys(srcIDs).length >= expectedReportsNo) { - dataPipeline.process.restore(); - resolve(); - } - }); - })) - .then(() => { - assert.sameDeepMembers( - processedReports.map((report) => { - assert.deepStrictEqual(report.dataCtx.data.system.qkviewNumber, '0000000', 'should have report data'); - return { - id: report.dataCtx.sourceId, - demo: report.opts.noConsumers, - consumers: report.dataCtx.destinationIds - }; - }), - [ - { id: 'f5telemetry_default::System::iHealthPoller_1', demo: true, consumers: ['f5telemetry_default::DefaultConsumer'] }, - { id: 'f5telemetry_default::System::iHealthPoller_1', demo: false, consumers: ['f5telemetry_default::DefaultConsumer'] }, - { id: 'Namespace::System::iHealthPoller_1', demo: false, consumers: ['Namespace::DefaultConsumer'] } - ], - 'should collect report from running pollers' - ); - return disabledAllPollers(); - }); - }); - - it('should ignore disabled pollers (existing poller)', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - const newDeclaration = testUtil.deepCopy(declaration); - newDeclaration.System.enable = false; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => configWorker.processDeclaration(newDeclaration)) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.deepStrictEqual( - newInstances.map((p) => p.id), - ['Namespace::System::iHealthPoller_1'], - 'should disable disabled only' - ); - assert.notDeepInclude( - instancesBefore, - newInstances[0], - 'should not create new instance when only namespace was updated' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(['Namespace::System::iHealthPoller_1']), - 'should create tracers with expected IDs' - ); - }); - }); - - it('should stop existing poller(s) when removed from config', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - const newDeclaration = testUtil.deepCopy(declaration); - delete newDeclaration.System; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => configWorker.processDeclaration(newDeclaration)) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.notDeepInclude( - instancesBefore, - newInstances[0], - 'should not create new instance when only namespace was updated' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(['Namespace::System::iHealthPoller_1']), - 'should create tracers with expected IDs' - ); - }); - }); - - it('should ignore disabled pollers (non-existing poller)', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - const newDeclaration = testUtil.deepCopy(declaration); - newDeclaration.New_System = testUtil.deepCopy(newDeclaration.System); - newDeclaration.New_System.enable = false; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => configWorker.processDeclaration(newDeclaration)) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.sameDeepMembers( - newInstances.map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - instancesBefore.forEach((inst) => { - assert.notDeepInclude(newInstances, inst, `should create new instance for ${inst.id}`); - }); - }); - }); - - it('should stop poller removed from System (existing poller with same system)', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - const newDeclaration = testUtil.deepCopy(declaration); - delete newDeclaration.System.iHealthPoller; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => configWorker.processDeclaration(newDeclaration)) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.notDeepInclude( - instancesBefore, - newInstances[0], - 'should not create new instance when only namespace was updated' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(['Namespace::System::iHealthPoller_1']), - 'should create tracers with expected IDs' - ); - }); - }); - - it('should ignore System without poller (non-existing poller)', () => { - const instancesBefore = IHealthPoller.getAll({ includeDemo: true }); - const newDeclaration = testUtil.deepCopy(declaration); - newDeclaration.New_System = testUtil.deepCopy(newDeclaration.System); - delete newDeclaration.New_System.iHealthPoller; - // starting 'demo' pollers that should be removed on update - return Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => configWorker.processDeclaration(testUtil.deepCopy(newDeclaration))) - .then(() => { - const newInstances = IHealthPoller.getAll({ includeDemo: true }); - assert.sameDeepMembers( - newInstances.map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - instancesBefore.forEach((inst) => { - assert.notDeepInclude(newInstances, inst, `should create new instance for ${inst.id}`); - }); - }); - }); - - it('should not fail when unable to start poller', () => { - sinon.stub(IHealthPoller.prototype, 'start').rejects(new Error('expected error')); - return configWorker.processDeclaration(testUtil.deepCopy(declaration)) - .then(() => { - assert.sameMembers( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - assert.includeMatch(coreStub.logger.messages.error, /expected error/, 'should log error'); - coreStub.logger.messages.error = []; - }); - }); - }); - - describe('.startPoller()', () => { - beforeEach(() => { - // slow down polling process - stubs.clock(); - declaration.System.enable = false; - declaration.Namespace.System.enable = false; - return configWorker.processDeclaration(testUtil.deepCopy(declaration)) - .then(() => { - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), 'should not create instances'); - assert.isEmpty(registeredTracerPaths(), 'should not create tracers'); - }); - }); - - it('should start poller', () => ihealth.startPoller('System') - .then((response) => { - assert.ownInclude(response, { - demoMode: true, - disabled: false, - id: 'f5telemetry_default::System::iHealthPoller_1', - name: 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', - isRunning: false, - message: 'iHealth Poller for System "System" started' - }); - assert.sameMembers( - IHealthPoller.getAll({ demoOnly: true }).map((p) => p.id), - ['f5telemetry_default::System::iHealthPoller_1'], - 'should create instance with expected ID' - ); - return ihealth.startPoller('System'); - }) - .then((response) => { - assert.ownInclude(response, { - demoMode: true, - disabled: false, - id: 'f5telemetry_default::System::iHealthPoller_1', - name: 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', - isRunning: true, - message: 'iHealth Poller for System "System" started already' - }); - })); - - it('should start poller declared in namespace', () => ihealth.startPoller('System', 'Namespace') - .then((response) => { - assert.ownInclude(response, { - demoMode: true, - disabled: false, - id: 'Namespace::System::iHealthPoller_1', - name: 'Namespace::System::iHealthPoller_1 (DEMO)', - isRunning: false, - message: 'iHealth Poller for System "System" (namespace "Namespace") started' - }); - assert.sameMembers( - IHealthPoller.getAll({ demoOnly: true }).map((p) => p.id), - ['Namespace::System::iHealthPoller_1'], - 'should create instance with expected ID' - ); - return ihealth.startPoller('System', 'Namespace'); - }) - .then((response) => { - assert.ownInclude(response, { - demoMode: true, - disabled: false, - id: 'Namespace::System::iHealthPoller_1', - name: 'Namespace::System::iHealthPoller_1 (DEMO)', - isRunning: true, - message: 'iHealth Poller for System "System" (namespace "Namespace") started already' - }); - })); - - it('should start disabled pollers', () => Promise.all([ - ihealth.startPoller('Disabled_System'), - ihealth.startPoller('Disabled_System', 'Namespace') - ]) - .then((responses) => { - assert.ownInclude(responses[0], { - demoMode: true, - disabled: false, - id: 'f5telemetry_default::Disabled_System::iHealthPoller_1', - name: 'f5telemetry_default::Disabled_System::iHealthPoller_1 (DEMO)', - isRunning: false, - message: 'iHealth Poller for System "Disabled_System" started' - }); - assert.ownInclude(responses[1], { - demoMode: true, - disabled: false, - id: 'Namespace::Disabled_System::iHealthPoller_1', - name: 'Namespace::Disabled_System::iHealthPoller_1 (DEMO)', - isRunning: false, - message: 'iHealth Poller for System "Disabled_System" (namespace "Namespace") started' - }); - assert.sameMembers( - IHealthPoller.getAll({ demoOnly: true }).map((p) => p.id), - [ - 'f5telemetry_default::Disabled_System::iHealthPoller_1', - 'Namespace::Disabled_System::iHealthPoller_1' - ], - 'should create instance with expected ID' - ); - return Promise.all([ - ihealth.startPoller('Disabled_System'), - ihealth.startPoller('Disabled_System', 'Namespace') - ]); - }) - .then((responses) => { - assert.ownInclude(responses[0], { - demoMode: true, - disabled: false, - id: 'f5telemetry_default::Disabled_System::iHealthPoller_1', - name: 'f5telemetry_default::Disabled_System::iHealthPoller_1 (DEMO)', - isRunning: true, - message: 'iHealth Poller for System "Disabled_System" started already' - }); - assert.ownInclude(responses[1], { - demoMode: true, - disabled: false, - id: 'Namespace::Disabled_System::iHealthPoller_1', - name: 'Namespace::Disabled_System::iHealthPoller_1 (DEMO)', - isRunning: true, - message: 'iHealth Poller for System "Disabled_System" (namespace "Namespace") started already' - }); - })); - - it('should reject on attempt to start non-existing poller', () => assert.isRejected( - ihealth.startPoller('NonExistingPoller'), - /System or iHealth Poller declaration not found/ - )); - - it('should reject on attempt to start non-existing poller in non-existing namespace', () => assert.isRejected( - ihealth.startPoller('NonExistingPoller', 'NonExistingNamespace'), - /System or iHealth Poller declaration not found/ - )); - - it('should reject on attempt to start non-existing poller in existing namespace', () => assert.isRejected( - ihealth.startPoller('NonExistingPoller', 'Namespace'), - /System or iHealth Poller declaration not found/ - )); - - it('should not fail when unable to start poller', () => { - sinon.stub(IHealthPoller.prototype, 'start').rejects(new Error('expected error')); - return ihealth.startPoller('System') - .then((response) => { - assert.ownInclude(response, { - isRunning: false - }); - assert.match(response.message, /Unable to start iHealth Poller for System "System".*expected error/); - }); - }); - }); - - describe('.getCurrentState()', () => { - beforeEach(() => { - // slow down polling process - stubs.clock(); - return configWorker.processDeclaration(testUtil.deepCopy(declaration)) - .then(() => { - assert.sameMembers( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - preExistingConfigIDs, - 'should create instances with expected IDs' - ); - assert.sameOrderedMatches( - registeredTracerPaths(), - toTracerPaths(preExistingConfigIDs), - 'should create tracers with expected IDs' - ); - return verifyPollersConfig(IHealthPoller.getAll({ includeDemo: true }), expectedConfiguration()); - }); - }); - - it('should return empty array when no pollers', () => configWorker.processDeclaration({ class: 'Telemetry' }) - .then(() => assert.isEmpty(ihealth.getCurrentState('NonExistingNamespace'), 'should return empty list'))); - - it('should return empty array for non-existing namespace', () => { - assert.isEmpty(ihealth.getCurrentState('NonExistingNamespace'), 'should return empty list'); - }); - - it('should return empty array for empty namespace', () => configWorker.processNamespaceDeclaration({ class: 'Telemetry_Namespace' }, 'Namespace') - .then(() => assert.isEmpty(ihealth.getCurrentState('Namespace'), 'should return empty list'))); - - it('should return statuses for all pollers', () => { - assert.sameDeepMembers( - ihealth.getCurrentState().map((s) => s.name), - [ - 'f5telemetry_default::System::iHealthPoller_1', - 'Namespace::System::iHealthPoller_1' - ], - 'should return all registered pollers' - ); - }); - - it('should return statuses for pollers in namespace', () => { - assert.sameDeepMembers( - ihealth.getCurrentState('Namespace').map((s) => s.name), - [ - 'Namespace::System::iHealthPoller_1' - ], - 'should return all registered pollers' - ); - }); - - it('should return statuses for demo pollers', () => Promise.all([ - ihealth.startPoller('System'), - ihealth.startPoller('System', 'Namespace') - ]) - .then(() => { - assert.sameDeepMembers( - ihealth.getCurrentState().map((s) => s.name), - [ - 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', - 'Namespace::System::iHealthPoller_1 (DEMO)', - 'f5telemetry_default::System::iHealthPoller_1', - 'Namespace::System::iHealthPoller_1' - ], - 'should return all registered pollers' - ); - })); - }); -}); diff --git a/test/unit/ihealth/api/deviceTests.js b/test/unit/ihealth/api/deviceTests.js new file mode 100644 index 00000000..16495936 --- /dev/null +++ b/test/unit/ihealth/api/deviceTests.js @@ -0,0 +1,546 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-restricted-syntax */ +const moduleCache = require('../../shared/restoreCache')(); + +const os = require('os'); +const pathUtil = require('path'); +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const BigIpApiMock = require('../../shared/bigipAPIMock'); +const bigipConnTests = require('../../shared/tests/bigipConn'); +const bigipCredsTest = require('../../shared/tests/bigipCreds'); +const { DeviceApiMock } = require('./mocks'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); +const testUtil = require('../../shared/util'); + +const DeviceAPI = sourceCode('src/lib/ihealth/api/device'); +const logger = sourceCode('src/lib/logger'); + +moduleCache.remember(); + +describe('iHealth / API / Device', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + const remotehost = 'remote.hostname.remote.domain'; + let coreStub; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true, utilMisc: true }); + }); + + afterEach(() => { + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('invalid arguments', () => { + assert.throws(() => new DeviceAPI(), /target host should be a string/); + assert.throws(() => new DeviceAPI(remotehost, { logger: null }), /logger should be an instance of Logger/); + + const hosts = [ + localhost, + remotehost + ]; + for (const host of hosts) { + const credsTests = bigipCredsTest(host); + for (const testData of credsTests) { + assert.throws(() => new DeviceAPI(host, { + credentials: testData.value, + logger + }), testData.error); + } + + const connTests = bigipConnTests(); + for (const testData of connTests) { + assert.throws(() => new DeviceAPI(host, { + connection: testData.value, + credentials: { token: 'token' }, + logger + }), testData.error); + } + } + }); + + it('should set properties', () => { + const device = new DeviceAPI(localhost, { + logger + }); + assert.deepStrictEqual(device.host, localhost); + assert.isDefined(device.logger); + assert.deepStrictEqual(device.connection, undefined); + assert.deepStrictEqual(device.credentials, {}); + }); + + it('should set non default properties and make copies', () => { + const connection = { + protocol: 'https', + port: 443, + allowSelfSignedCert: true + }; + const credentials = { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + }; + const device = new DeviceAPI(remotehost, { + connection, + credentials, + logger + }); + + connection.protocol = 'http'; + connection.port = 80; + connection.allowSelfSignedCert = false; + credentials.username = 'test_user_2'; + credentials.passphrase = 'test_passphrase_2'; + + assert.deepStrictEqual(device.host, remotehost); + assert.isDefined(device.logger); + assert.deepStrictEqual(device.connection, { + protocol: 'https', + port: 443, + allowSelfSignedCert: true + }); + assert.deepStrictEqual(device.credentials, { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + }); + }); + }); + + const combinations = testUtil.product( + // host config + [ + { + name: localhost, + value: localhost + }, + { + name: remotehost, + value: remotehost + } + ], + // credentials config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'admin with passphrase', + value: { username: defaultUser, passphrase: 'test_passphrase_1' } + }, + testUtil.smokeTests.ignore({ + name: 'non-default user', + value: { username: 'test_user_1', passphrase: 'test_passphrase_2' } + }), + testUtil.smokeTests.ignore({ + name: 'non-default passwordless user', + value: { username: 'test_user_1' } + }), + { + name: 'existing token', + value: { token: 'auto' } + } + ]), + // connection config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'non default', + value: { port: 8105, protocol: 'https', allowSelfSignedCert: true } + } + ]) + ); + + combinations.forEach(([hostConf, credentialsConf, connectionConf]) => { + if (hostConf.value === remotehost && !(credentialsConf.value && credentialsConf.value.passphrase)) { + // password-less user does not work with remote host + return; + } + + describe(`host = ${hostConf.name}, user = ${credentialsConf.name}, connection = ${connectionConf.name}`, () => { + let bigip; + let connection; + let credentials; + let device; + let deviceMock; + let host; + let requestSpies; + + function makeAuthOptional() { + if (deviceMock.authMock) { + deviceMock.authMock.disable(); + } + } + + function makeDacliStub(stub) { + stub.updateScript.interceptor.optionally(true); + return stub; + } + + beforeEach(() => { + requestSpies = testUtil.requestSpies(); + + let authMock = null; + connection = testUtil.deepCopy(connectionConf.value); + credentials = testUtil.deepCopy(credentialsConf.value); + host = hostConf.value; + + bigip = new BigIpApiMock(host, { + port: (connection && connection.port) || undefined, + protocol: (connection && connection.protocol) || undefined + }); + + if (credentials && credentials.token) { + bigip.addAuthToken(credentials.token); + } else if (host === remotehost && credentials) { + assert.allOfAssertions( + () => assert.isDefined(credentials.username, 'username should be defined for remote host'), + () => assert.isDefined(credentials.passphrase, 'passphrase should be defined for remote host') + ); + authMock = bigip.mockAuth(credentials.username, credentials.passphrase); + } else if (host === localhost) { + bigip.addPasswordlessUser( + (credentials && credentials.username) + ? credentials.username + : defaultUser + ); + } + + device = new DeviceAPI(host, { connection, credentials, logger }); + deviceMock = new DeviceApiMock(bigip); + deviceMock.authMock = authMock; + }); + + afterEach(() => { + let strictSSL = true; + if (connectionConf.value && typeof connectionConf.value.allowSelfSignedCert === 'boolean') { + strictSSL = !connectionConf.value.allowSelfSignedCert; + } + testUtil.checkRequestSpies(requestSpies, { strictSSL }); + }); + + function addDacliTests(fn, getStub) { + it('should throw error when unable to execute command via DACLI', () => { + getStub().pollTaskResult.stub.callsFake(() => [200, { _taskState: 'FAILED' }]); + return assert.isRejected(fn(device), /DeviceAsyncCLI.execute: Task failed/); + }); + } + + function addDeviceAuthTests(fn) { + it('should throw error when unable to authorize', function () { + if (deviceMock.authMock) { + deviceMock.authMock.stub.callsFake(() => [404, 'expected auth error']); + return assert.isRejected(fn(device), /Bad status code: 404/); + } + return this.skip(); + }); + } + + function addDeviceExecuteShellCmdTests(fn, getStub) { + it('should throw error when unable to execute shell command', () => { + getStub().stub.callsFake(() => [500, null, 'error']); + return assert.isRejected(fn(device), /Bad status code: 500/); + }); + } + + function addDeviceRemoveFileTests(fn, getStub) { + it('should not throw error when unable to remove file', () => { + getStub().stub.callsFake(() => [200, 'error']); + return fn(device); + }); + } + + function wrapMD5(fpath) { + return `${fpath}.md5sum`; + } + + describe('.createQkview()', () => { + const qkviewName = 'myqkview'; + const fn = (inst, dir) => inst.createQkview(qkviewName, dir); + + addDeviceAuthTests(fn); + addDacliTests(fn, () => makeDacliStub(deviceMock.mockCreateQkview(qkviewName).dacli)); + + it('should throw error when no file name provided', () => { + makeAuthOptional(); + return Promise.all([ + assert.isRejected(device.createQkview(), 'Qkview file name should be a string'), + assert.isRejected(device.createQkview(''), 'Qkview file name should be a non-empty collection') + ]); + }); + + it('should throw error when no directory path provided', () => { + makeAuthOptional(); + return Promise.all([ + assert.isRejected(device.createQkview('scrFile', ''), 'directory should be a non-empty collection'), + assert.isRejected(device.createQkview('srcFile', null), 'directory should be a string') + ]); + }); + + [ + { + name: 'default dir', + value: undefined + }, + { + name: 'custom dir', + value: '/tmp' + } + ].forEach((testConf) => { + describe(testConf.name, () => { + beforeEach(() => { + deviceMock.mockCreateQkview(qkviewName, { dir: testConf.value }); + }); + + it('should create Qkview', () => assert.becomes( + fn(device, testConf.value), + `${testConf.value || '/shared/tmp'}/${qkviewName}` + )); + }); + }); + }); + + describe('.downloadFile()', () => { + const tmpDir = os.tmpdir(); + const dstFile = pathUtil.join(tmpDir, 'testDownloadFileUserStream'); + const scrFile = '/shared/tmp/source'; + + beforeEach(async () => { + await coreStub.utilMisc.fs.promise.mkdir(tmpDir); + }); + + const fn = (inst) => inst.downloadFile(scrFile, dstFile); + + it('should throw error when no file path provided', () => { + makeAuthOptional(); + return Promise.all([ + assert.isRejected(device.downloadFile(), 'source file should be a string'), + assert.isRejected(device.downloadFile(''), 'source file should be a non-empty collection'), + assert.isRejected(device.downloadFile(scrFile), 'destination file should be a string'), + assert.isRejected(device.downloadFile(scrFile, ''), 'destination file should be a non-empty collection') + ]); + }); + + describe('auth test', () => { + addDeviceAuthTests(fn); + }); + + describe('main tests', () => { + let downloadStub; + + beforeEach(() => { + downloadStub = deviceMock.mockDownloadFile(scrFile); + }); + + it('should throw error when source path does not exist', async () => { + downloadStub.remotePathExist.stub.callsFake(() => [200, '']); + + downloadStub.downloadFile.remove(); + downloadStub.downloadPathExist.remove(); + downloadStub.removeDownloadPath.remove(); + downloadStub.symlinkStub.remove(); + + await assert.isRejected( + fn(device), + /pathExists.* doesn't exist/ + ); + }); + + it('should throw error when unable to create a symlink', async () => { + downloadStub.symlinkStub.stub.returns([500, 'error']); + + downloadStub.downloadFile.remove(); + downloadStub.downloadPathExist.remove(); + downloadStub.removeDownloadPath.remove(); + + await assert.isRejected( + fn(device), + /Bad status code: 500/ + ); + }); + + it('should throw error when symlink does not exist', async () => { + downloadStub.downloadPathExist.stub.callsFake(() => [200, '']); + + downloadStub.downloadFile.remove(); + downloadStub.removeDownloadPath.remove(); + + await assert.isRejected( + fn(device), + /pathExists.* doesn't exist/ + ); + }); + + it('should remove remote file when unable to download it', async () => { + downloadStub.downloadFile.stub.returns([404, null, null, 'expected file error']); + await assert.isRejected( + fn(device), + /downloadFileFromDevice: HTTP Error: 404/ + ); + + assert.deepStrictEqual(downloadStub.removeDownloadPath.stub.callCount, 1); + }); + + it('should remove remote file when file downloaded', async () => { + await fn(device); + assert.deepStrictEqual(downloadStub.removeDownloadPath.stub.callCount, 1); + + assert.deepStrictEqual( + coreStub.utilMisc.fs.promise.readFileSync(dstFile).toString(), + 'qkview' + ); + }); + }); + }); + + describe('.getDeviceInfo()', () => { + const fn = (inst) => inst.getDeviceInfo(); + + addDeviceAuthTests(fn); + + it('should gather device info', () => { + const expected = deviceMock.mockGetDeviceInfo().deviceInfo.deviceInfoData; + delete expected.generation; + delete expected.kind; + delete expected.lastUpdateMicros; + delete expected.selfLink; + + return assert.becomes( + fn(device), + expected + ); + }); + }); + + describe('.getMD5sum()', () => { + const fpath = 'myfile'; + + const fn = (inst) => inst.getMD5sum(fpath); + + it('should throw error when no file path provided', () => { + makeAuthOptional(); + + return Promise.all([ + assert.isRejected(device.getMD5sum(), 'path to a file should be a string'), + assert.isRejected(device.getMD5sum(''), 'path to a file should be a non-empty collection') + ]); + }); + + describe('auth test', () => { + addDeviceAuthTests(fn); + }); + + describe('main tests', () => { + let getMD5sumStub; + + beforeEach(() => { + getMD5sumStub = deviceMock.mockGetMD5sum(fpath); + makeDacliStub(getMD5sumStub.dacli); + }); + + describe('dacli test', () => { + beforeEach(() => { + getMD5sumStub.shell.disable(); + }); + + addDacliTests(fn, () => getMD5sumStub.dacli); + }); + + addDeviceExecuteShellCmdTests(fn, () => getMD5sumStub.shell); + addDeviceRemoveFileTests(fn, () => getMD5sumStub.removePath); + + it('should calculate MD5 sum', async () => { + await assert.becomes( + fn(device), + `${fpath}_md5sum` + ); + + assert.deepStrictEqual(getMD5sumStub.removePath.stub.callCount, 1); + }); + + it('should throw error when MD5 sum is empty', async () => { + getMD5sumStub.shell.stub.returns([200, '']); + await assert.isRejected( + fn(device), `MD5 file "${wrapMD5(fpath)}" is empty!` + ); + + assert.deepStrictEqual(getMD5sumStub.removePath.stub.callCount, 1, 'should try remove file even when error thrown'); + }); + }); + }); + + describe('.removeFile()', () => { + const fpath = 'myfile'; + const fn = (inst) => inst.removeFile(fpath); + + addDeviceAuthTests(fn); + + it('should throw error when no file path provided', () => { + makeAuthOptional(); + return Promise.all([ + assert.isRejected(device.removeFile(), 'path to a file should be a string'), + assert.isRejected(device.removeFile(''), 'path to a file should be a non-empty collection') + ]); + }); + + describe('main tests', () => { + let removeFileStub; + + beforeEach(() => { + removeFileStub = deviceMock.mockRemoveFile(fpath); + }); + + it('should not throw error when unable to remove file', async () => { + removeFileStub.removePath.interceptor.times(2); + removeFileStub.removePath.stub + .onFirstCall().callsFake(() => [200, 'error']) + .onSecondCall().callsFake(() => [404, null, 'error']); + + await fn(device); + await fn(device); + }); + + it('should not throw error when unable to remove file (socket error)', async () => { + removeFileStub.removePath.remove(); + removeFileStub.removePath.interceptor.replyWithError({ code: 500, message: 'test' }); + + await fn(device); + }); + + it('should remove file', () => assert.isFulfilled( + fn(device) + )); + }); + }); + }); + }); +}); diff --git a/test/unit/ihealth/api/ihealthTests.js b/test/unit/ihealth/api/ihealthTests.js new file mode 100644 index 00000000..ce1c82b0 --- /dev/null +++ b/test/unit/ihealth/api/ihealthTests.js @@ -0,0 +1,447 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-restricted-syntax */ +const moduleCache = require('../../shared/restoreCache')(); + +const pathUtil = require('path'); +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const httpProxyTests = require('../../shared/tests/httpProxy'); +const ihealthCredsTests = require('../../shared/tests/ihealthCreds'); +const { IHealthApiMock } = require('./mocks'); +const qkviewDiagData = require('./qkviewDiagnostics.json'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); +const testUtil = require('../../shared/util'); + +const IHealthAPI = sourceCode('src/lib/ihealth/api/ihealth'); +const logger = sourceCode('src/lib/logger'); +const { SERVICE_API } = sourceCode('src/lib/constants').IHEALTH; + +moduleCache.remember(); + +describe('iHealth / API / IHealth', () => { + const credentials = { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + }; + let fakeClock; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + fakeClock = null; + }); + + afterEach(() => { + if (fakeClock) { + fakeClock.stub.restore(); + } + + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('invalid arguments', () => { + const ihealthCreds = ihealthCredsTests(); + for (const testData of ihealthCreds) { + assert.throws(() => new IHealthAPI(testData.value), testData.error); + } + + const proxyTests = httpProxyTests(); + for (const testData of proxyTests) { + assert.throws(() => new IHealthAPI(credentials, { logger, proxy: testData.value }), testData.error); + } + }); + }); + + describe('main tests', () => { + const qkviewFilePath = __filename; + let ihealthApiMock; + let ihealth; + let requestSpies; + let uploadStub; + + const proxyTests = [ + { + name: 'no proxy', + value: undefined, + expectedProxy: undefined + }, + { + name: 'host only', + value: { connection: { host: 'proxyhost' } }, + expectedProxy: 'http://proxyhost' + }, + { + name: 'host, port, protocol, allowSelfSignedCert', + value: { + connection: { + host: 'proxyhost', + port: 5555, + protocol: 'https', + allowSelfSignedCert: true + } + }, + expectedProxy: 'https://proxyhost:5555' + }, + { + name: 'with username', + value: { + connection: { + host: 'proxyhost', + port: 5555, + protocol: 'https', + allowSelfSignedCert: true + }, + credentials: { + username: 'test_user_1' + } + }, + expectedProxy: 'https://test_user_1@proxyhost:5555' + }, + { + name: 'with username and passphrase', + value: { + connection: { + host: 'proxyhost', + port: 5555, + protocol: 'https', + allowSelfSignedCert: true + }, + credentials: { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + } + }, + expectedProxy: 'https://test_user_1:test_passphrase_1@proxyhost:5555' + } + ]; + + proxyTests.forEach((proxyTest) => describe(`proxy - ${proxyTest.name}`, () => { + let authStub; + + beforeEach(() => { + requestSpies = testUtil.requestSpies(); + stubs.default.coreStub({ logger: true }); + + ihealthApiMock = new IHealthApiMock(); + authStub = ihealthApiMock.mockAuth(credentials.username, credentials.passphrase); + + ihealth = new IHealthAPI(credentials, { logger, proxy: proxyTest.value }); + uploadStub = ihealthApiMock.mockQkviewUpload(pathUtil.basename(__filename)); + }); + + afterEach(() => { + let strictSSL = true; + if (proxyTest.value && typeof proxyTest.value.connection.allowSelfSignedCert === 'boolean') { + strictSSL = !proxyTest.value.connection.allowSelfSignedCert; + } + testUtil.checkRequestSpies(requestSpies, { strictSSL, proxy: proxyTest.expectedProxy }); + }); + + describe('.uploadQkview()', () => { + it('should upload qkview', async () => { + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + assert.deepStrictEqual(uploadStub.stub.callCount, 1); + assert.isTrue(qkviewURI.startsWith(SERVICE_API.UPLOAD)); + }); + + const responseTests = [ + { + name: 'invalid JSON', + value: '{something}', + error: /unable to parse response body/ + }, + { + name: 'invalid response type', + value: 10, + error: /response should be an object/ + }, + { + name: 'invalid response structure', + value: { data: true }, + error: /response.result should be a string/ + }, + { + name: 'invalid response.result type', + value: { result: 10 }, + error: /response.result should be a string/ + }, + { + name: 'invalid response.result value', + value: { result: 'FAIL' }, + error: /response.result should be "OK"/ + }, + { + name: 'invalid response.result value (empty string)', + value: { result: '' }, + error: /response.result should be a non-empty collection/ + }, + { + name: 'invalid response.location value (empty string)', + value: { result: 'OK', location: '' }, + error: /response.location should be a non-empty collection/ + }, + { + name: 'invalid response.location type', + value: { result: 'OK', location: 10 }, + error: /response.location should be a string/ + } + ]; + + responseTests.forEach((testData) => { + it(`should fail when unable to process response - ${testData.name}`, async () => { + uploadStub.stub.returns([303, testData.value]); + await assert.isRejected( + ihealth.uploadQkview(qkviewFilePath), + testData.error + ); + }); + }); + }); + + describe('.fetchQkviewDiagnostics()', () => { + let diagnosticsStub; + let reportStub; + + beforeEach(() => { + diagnosticsStub = ihealthApiMock.mockQkviewDiagnostics(); + reportStub = ihealthApiMock.mockQkviewReport(); + }); + + it('should fetch diagnostics data', async () => { + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + const report = await ihealth.fetchQkviewDiagnostics(qkviewURI); + + assert.deepStrictEqual( + report.qkviewURI, qkviewURI + ); + assert.deepStrictEqual( + report.status, + { + done: true, + error: false + } + ); + assert.isTrue(report.diagnosticsURI.startsWith(SERVICE_API.UPLOAD)); + assert.isTrue(report.diagnosticsURI.endsWith('/diagnostics')); + assert.deepStrictEqual(report.diagnostics, qkviewDiagData); + }); + + const reportTests = [ + { + name: 'invalid JSON', + value: '{something}', + error: /Unable to parse Qkview report response/ + }, + { + name: 'invalid report type', + value: 10, + error: /report should be an object/ + }, + { + name: 'invalid report structure', + value: { data: true }, + error: /report.processing_status should be a string/ + }, + { + name: 'invalid report.processing_status type', + value: { processing_status: 10 }, + error: /report.processing_status should be a string/ + }, + { + name: 'invalid report.processing_status value', + value: { processing_status: '' }, + error: /report.processing_status should be a non-empty collection/ + }, + { + name: 'invalid report.diagnostics type', + value: { processing_status: 'COMPLETE', diagnostics: 10 }, + error: /report.diagnostics should be a string/ + }, + { + name: 'invalid report.diagnostics value', + value: { processing_status: 'COMPLETE', diagnostics: '' }, + error: /report.diagnostics should be a non-empty collection/ + } + ]; + + reportTests.forEach((testData) => { + it(`should fail when unable to process report response - ${testData.name}`, async () => { + diagnosticsStub.remove(); + reportStub.stub.returns([200, testData.value]); + + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + + await assert.isRejected( + ihealth.fetchQkviewDiagnostics(qkviewURI), + testData.error + ); + }); + }); + + const diagnosticsTests = [ + { + name: 'invalid JSON', + value: '{something}', + error: /Unable to parse Qkview diagnostics response/ + }, + { + name: 'invalid report type', + value: 10, + error: /response should be an object/ + }, + { + name: 'invalid report structure', + value: { data: true }, + error: /response.diagnostics should be an object/ + }, + { + name: 'invalid report structure', + value: { diagnostics: true }, + error: /response.diagnostics should be an object/ + }, + { + name: 'invalid report structure', + value: { diagnostics: 10 }, + error: /response.diagnostics should be an object/ + }, + { + name: 'invalid report structure', + value: { diagnostics: {} }, + error: /response.diagnostics should be a non-empty collection/ + } + ]; + + diagnosticsTests.forEach((testData) => { + it(`should fail when unable to process diagnostics response - ${testData.name}`, async () => { + diagnosticsStub.stub.returns([200, testData.value]); + + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + + await assert.isRejected( + ihealth.fetchQkviewDiagnostics(qkviewURI), + testData.error + ); + }); + }); + + it('should be able to process "RUNNING" report status', async () => { + diagnosticsStub.remove(); + reportStub.stub.callsFake((qkviewID, template) => [ + 200, + { + processing_status: 'RUNNING', + diagnostics: template.diagnostics + } + ]); + + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + const report = await ihealth.fetchQkviewDiagnostics(qkviewURI); + + assert.deepStrictEqual(report, { + qkviewURI, + status: { + done: false, + error: false + } + }); + }); + + it('should be able to process "ERROR" report status', async () => { + diagnosticsStub.remove(); + reportStub.stub.callsFake((qkviewID, template) => [ + 200, + { + processing_messages: 'expected error', + processing_status: 'ERROR', + diagnostics: template.diagnostics + } + ]); + + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + const report = await ihealth.fetchQkviewDiagnostics(qkviewURI); + + assert.deepStrictEqual(report, { + qkviewURI, + status: { + done: true, + error: true, + errorMessage: 'expected error' + } + }); + }); + + it('should be able to process "OTHER" report status', async () => { + diagnosticsStub.remove(); + reportStub.stub.callsFake((qkviewID, template) => [ + 200, + { + processing_status: 'OTHER', + diagnostics: template.diagnostics + } + ]); + + const qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + const report = await ihealth.fetchQkviewDiagnostics(qkviewURI); + + assert.deepStrictEqual(report, { + qkviewURI, + status: { + done: false, + error: false + } + }); + }); + + it('should re-auth when token expired', async () => { + authStub.interceptor.times(2); + uploadStub.interceptor.times(2); + + let qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + assert.isTrue(qkviewURI.startsWith(SERVICE_API.UPLOAD)); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + await fakeClock.clockForward(2000 * 10000, { repeat: 1, promisify: true, delay: 1 }); + + qkviewURI = null; + await Promise.all([ + (async () => { + qkviewURI = await ihealth.uploadQkview(qkviewFilePath); + })(), + (async () => { + while (!qkviewURI) { + await fakeClock.clockForward(100, { repeat: 1, promisify: true, delay: 1 }); + } + fakeClock.stub.restore(); + })() + ]); + + assert.isTrue(qkviewURI.startsWith(SERVICE_API.UPLOAD)); + assert.deepStrictEqual(authStub.stub.callCount, 2); + }); + }); + })); + }); +}); diff --git a/test/unit/ihealth/api/mocks.js b/test/unit/ihealth/api/mocks.js new file mode 100644 index 00000000..fa1173e6 --- /dev/null +++ b/test/unit/ihealth/api/mocks.js @@ -0,0 +1,561 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const pathUtil = require('path'); +const sinon = require('sinon'); +const { v4: uuidv4 } = require('uuid'); + +const assert = require('../../shared/assert'); +const BigIpApiMock = require('../../shared/bigipAPIMock'); +const { + mockHttpEndpoint, + wrapMockNock, + wrapMockNockSet +} = require('../../shared/httpMock'); +const qkviewDiagData = require('./qkviewDiagnostics.json'); +const testUtil = require('../../shared/util'); + +/** + * @typedef {import('../../shared/bigipAPIMock').Connection} Connection + * @typedef {import('../../shared/bigipAPIMock').MockNockStubBase} MockNockStubBase + */ + +/** + * Device API Mock + */ +class DeviceApiMock { + /** + * @param {BigIpApiMock} bigip + */ + constructor(bigip) { + assert.instanceOf(bigip, BigIpApiMock, `bigip should be an instnace of ${BigIpApiMock.name}`); + this.bigip = bigip; + } + + /** + * Mock DeviceAPI.prototype.createQkview + * + * @property {string} [qkviewName = ''] + * @property {NockMockOptions} [options] + * @property {string} [options.dir] + * + * @returns {{dacli: Object}} stubs + */ + mockCreateQkview(qkviewName = '', options = {}) { + assert.isString(qkviewName, 'qkviewName'); + + let dir = '/shared/tmp'; + if (options.dir) { + assert.isString(options.dir, 'directory'); + dir = options.dir; + } + + const dacliCmd = new RegExp(`/usr/bin/qkview -C -f ../..${dir}/${qkviewName}`); + const createQkviewStub = { + dacli: this.bigip.mockDACLI(dacliCmd, Object.assign({}, testUtil.deepCopy(options), { scriptName: /qkview/ })) + }; + createQkviewStub.dacli.updateScript.remove(); + + wrapMockNockSet(createQkviewStub); + return createQkviewStub; + } + + /** + * Mock DeviceAPI.prototype.downloadFile + * + * @property {string} srcPath + * @property {NockMockOptions} [options] + * + * @returns {{ + * downloadownloadFiledStub: MockNockStubBase, + * downloadPathExist: MockNockStubBase, + * remotePathExist: MockNockStubBase, + * removeDownloadPath: MockNockStubBase, + * symlinkStub: MockNockStubBase, + * }} stubs + */ + mockDownloadFile(srcPath, options = {}) { + const downloadURI = new RegExp(`/mgmt/shared/file-transfer/bulk/${pathUtil.basename(srcPath)}`); + const remoteFilePath = new RegExp(`/var/config/rest/bulk/${pathUtil.basename(srcPath)}`); + const symlinkCmd = new RegExp(`ln -s \\\\"${srcPath}\\\\" \\\\"/var/config/rest/bulk/${pathUtil.basename(srcPath)}\\\\"`); + + const downloadFileStub = {}; + + downloadFileStub.downloadFile = this.bigip.mockDownloadFileFromDevice(downloadURI, testUtil.deepCopy(options)); + downloadFileStub.downloadFile.stub.callsFake(() => { + const data = Buffer.from('qkview'); + return [200, data, { start: 0, end: data.length - 1, size: data.length }]; + }); + downloadFileStub.downloadPathExist = this.bigip.mockPathExists(remoteFilePath, testUtil.deepCopy(options)); + downloadFileStub.downloadPathExist.stub.callsFake((pathArg) => [200, pathArg]); + downloadFileStub.remotePathExist = this.bigip.mockPathExists(new RegExp(srcPath), testUtil.deepCopy(options)); + downloadFileStub.remotePathExist.stub.callsFake((pathArg) => [200, pathArg]); + downloadFileStub.removeDownloadPath = this.bigip.mockRemovePath(remoteFilePath, testUtil.deepCopy(options)); + downloadFileStub.symlinkStub = this.bigip.mockExecuteShellCommandOnDevice( + symlinkCmd, testUtil.deepCopy(options) + ); + + wrapMockNockSet(downloadFileStub); + return downloadFileStub; + } + + /** + * Mock DeviceAPI.prototype.getDeviceInfo + * + * @property {NockMockOptions} [options] + * + * @returns {{deviceInfo: MockNockStubBase}} stubs + */ + mockGetDeviceInfo(options = {}) { + const deviceInfoStub = { + deviceInfo: this.bigip.mockDeviceInfo(undefined, testUtil.deepCopy(options)) + }; + return deviceInfoStub; + } + + /** + * Mock DeviceAPI.prototype.getMD5sum + * + * @property {string} filePath + * @property {NockMockOptions} [options] + * + * @returns {{ + * dacli: Object, + * removePath: MockNockStubBase, + * shell: MockNockStubBase + * }} stubs + */ + mockGetMD5sum(filePath, options = {}) { + const dacliCmd = new RegExp(`md5sum "${filePath}" > "${wrapMD5(filePath)}"`); + const shellCmd = new RegExp(`cat \\\\"${wrapMD5(filePath)}\\\\"`); + + const getMD5sumStub = {}; + getMD5sumStub.dacli = this.bigip.mockDACLI(dacliCmd, Object.assign({}, testUtil.deepCopy(options), { scriptName: /md5/ })); + getMD5sumStub.dacli.updateScript.remove(); + + getMD5sumStub.removePath = this.bigip.mockRemovePath(new RegExp(wrapMD5(filePath)), testUtil.deepCopy(options)); + getMD5sumStub.shell = this.bigip.mockExecuteShellCommandOnDevice(shellCmd, testUtil.deepCopy(options)); + getMD5sumStub.shell.stub.returns([200, `${filePath}_md5sum ${filePath}`]); + + wrapMockNockSet(getMD5sumStub); + return getMD5sumStub; + } + + /** + * Mock DeviceAPI.prototype.removeFile + * + * @property {RegExp | string} filePath + * @property {NockMockOptions} [options] + * + * @returns {{removePath: MockNockStubBase}} stubs + */ + mockRemoveFile(filePath, options = {}) { + const removeFileStub = { + removePath: this.bigip.mockRemovePath(new RegExp(filePath), testUtil.deepCopy(options)) + }; + + wrapMockNockSet(removeFileStub); + return removeFileStub; + } +} + +class IHealthApiMock { + constructor() { + Object.defineProperties(this, { + apiOrigin: { + value: 'https://ihealth2-api.f5.com' + }, + identityOrigin: { + value: 'https://identity.account.f5.com' + } + }); + this.authTokens = []; + this.qkviews = {}; + } + + /** + * @param {{access_token: string}} token + */ + addAuthToken(token) { + assert.allOfAssertions( + () => assert.allOfAssertions( + () => assert.isObject(token, 'token'), + () => assert.isNotEmpty(token, 'token'), + 'token should be a non-emtpy object' + ), + () => assert.allOfAssertions( + () => assert.isString(token.access_token, 'token.access_token'), + () => assert.isNotEmpty(token.access_token, 'token.access_token'), + 'token.access_token should be a string' + ) + ); + this.authTokens.push(token.access_token); + } + + /** + * @param {string} header - authorization header value + * + * @returns {boolean} true if auth token is valid + */ + checkAuth(header) { + return this.authTokens.some((token) => header.includes(`Bearer ${token}`)); + } + + /** + * @param {string} username + * @param {string} password + * @property {NockMockOptions} [options] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(authData: {user_id: string, user_secret: string}) { + * return [statusCode, 'token', 'error message' || null]; + * } + */ + mockAuth(username, password, { optionally = false, replyTimes = 1 } = {}) { + Object.entries({ username, password }).forEach(([key, value]) => assert.oneOfAssertions( + () => assert.instanceOf(value, RegExp, key), + () => assert.allOfAssertions( + () => assert.isString(value, key), + () => assert.isNotEmpty(value, key) + ), + `${key} should be a RegExp or a string` + )); + + function testStr(expected, actual) { + if (typeof expected === 'string') { + return expected === actual; + } + + expected.lastIndex = 0; + return expected.test(actual); + } + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.identityOrigin, { + method: 'POST', + optionally, + path: '/oauth2/ausp95ykc80HOU7SQ357/v1/token', + reqBody: 'grant_type=client_credentials&scope=ihealth', + reqHeaders: { + Accept: 'application/json', + Authorization: /Basic\s+.{1,}/, + 'Cache-Control': 'no-cache', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + replyTimes, + response: async (uri, reqBody, req) => { + const authHeader = req.headers.authorization || ''; + if (this.checkAuth(authHeader)) { + return [500, 'Invalid cookies']; + } + + const creds = Buffer.from( + authHeader.trim().split(/\s+/)[1], 'base64' + ) + .toString() + .split(':'); + + if (!(creds.length === 2 && testStr(username, creds[0] || '') && testStr(password, creds[1] || ''))) { + return [401, 'Unauthorized']; + } + + const [code, token, errorMsg] = ret.stub(reqBody); + if (token) { + this.addAuthToken(token); + } + return [ + code, + errorMsg || token + ]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake(() => [ + 200, + { + access_token: uuidv4(), + expires_in: 1800, + scope: 'ihealth', + token_type: 'Bearer' + } + ]); + + return ret; + } + + /** + * @param {object} diagData + * @param {object} [options] + * @property {NockMockOptions} [options] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(qkviewID: string, template: object) { + * return [statusCode, template, 'error message' || null]; + * } + */ + mockQkviewDiagnostics(diagData, { optionally = false, replyTimes = 1 } = {}) { + const qkviewURI = '/qkview-analyzer/api/qkviews/'; + const uriSuffix = '/diagnostics'; + + function fetchQkviewIdFromURI(uri) { + if (uri.startsWith(qkviewURI) && uri.endsWith(uriSuffix)) { + return uri.slice(qkviewURI.length, uri.length - uriSuffix.length); + } + return ''; + } + + diagData = testUtil.deepCopy(diagData || qkviewDiagData); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.apiOrigin, { + method: 'GET', + optionally, + path: (uri) => !!this.qkviews[fetchQkviewIdFromURI(uri)], + reqHeaders: { + Accept: 'application/vnd.f5.ihealth.api.v1.0+json', + Authorization: /Bearer\s+.{1,}/ + }, + replyTimes, + response: async (uri, reqBody, req) => { + if (!this.checkAuth(req.headers.authorization || '')) { + return [401, 'Unauthorized']; + } + + const [code, data, errorMsg] = ret.stub(fetchQkviewIdFromURI(uri), testUtil.deepCopy(ret.diagData)); + return [ + code, + errorMsg || data + ]; + } + }), + diagData, + stub: sinon.stub() + }); + ret.stub.callsFake((qkviewID, template) => [200, template]); + + return ret; + } + + /** + * @param {object} [options] + * @property {NockMockOptions} [options] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(qkviewID: string, template: object) { + * return [statusCode, template, 'error message' || null]; + * } + */ + mockQkviewReport({ optionally = false, replyTimes = 1 } = {}) { + const qkviewURI = '/qkview-analyzer/api/qkviews/'; + + function fetchQkviewIdFromURI(uri) { + if (uri.startsWith(qkviewURI)) { + return uri.slice(qkviewURI.length); + } + return ''; + } + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.apiOrigin, { + method: 'GET', + optionally, + path: (uri) => !!this.qkviews[fetchQkviewIdFromURI(uri)], + reqHeaders: { + Accept: 'application/vnd.f5.ihealth.api.v1.0+json', + Authorization: /Bearer\s+.{1,}/ + }, + replyTimes, + response: async (uri, reqBody, req) => { + if (!this.checkAuth(req.headers.authorization || '')) { + return [401, 'Unauthorized']; + } + + const qkviewID = fetchQkviewIdFromURI(uri); + const template = { + processing_status: 'COMPLETE', + processing_messages: null, + diagnostics: `${this.qkviews[qkviewID].location}/diagnostics` + }; + + const [code, data, errorMsg] = ret.stub(qkviewID, template); + return [ + code, + errorMsg || data + ]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake((qkviewID, template) => [200, template]); + + return ret; + } + + /** + * @param {string} qkviewFilename + * @property {NockMockOptions} [options] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(data: string, responseTemplate: object) { + * return [statusCode, responseTemplate, 'error message' || null]; + * } + */ + mockQkviewUpload(qkviewFilename, { optionally = false, replyTimes = 1 } = {}) { + assert.allOfAssertions( + () => assert.isString(qkviewFilename, 'qkviewFilename'), + () => assert.isNotEmpty(qkviewFilename, 'qkviewFilename'), + 'qkviewFilename should be a string' + ); + + const fnameRegExp = new RegExp(`filename=\\"${qkviewFilename}\\"`, 'gm'); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.apiOrigin, { + method: 'POST', + optionally, + path: '/qkview-analyzer/api/qkviews', + reqBody(reqBody) { + fnameRegExp.lastIndex = 0; + return fnameRegExp.test(reqBody); + }, + reqHeaders: { + Accept: 'application/vnd.f5.ihealth.api.v1.0+json', + Authorization: /Bearer\s+.{1,}/ + }, + replyTimes, + response: async (uri, reqBody, req) => { + if (!this.checkAuth(req.headers.authorization || '')) { + return [401, 'Unauthorized']; + } + + const reqID = Date.now(); + const template = { + expiration_date: Date.now() + 300 * 1000, + id: reqID, + location: `${this.apiOrigin}/qkview-analyzer/api/qkviews/${reqID}`, + result: 'OK' + }; + + const [code, data, errorMsg] = ret.stub(reqBody, template); + if (!errorMsg) { + this.qkviews[reqID] = { + id: reqID, + fileData: reqBody, + location: template.location + }; + } + return [ + code, + errorMsg || data + ]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake((data, template) => [303, template]); + + return ret; + } +} + +/** + * Qkview Manager Mock + */ +class QkviewManagerMock { + /** + * @param {DeviceApiMock} localMock + * @param {DeviceApiMock} remoteMock + */ + constructor(localMock, remoteMock) { + this.local = localMock; + this.remote = remoteMock; + } + + /** + * Mock all methods that applicable for the local device + * + * @param {string} [qkviewName] + * @property {NockMockOptions} [options] + * + * @returns {object} stubs + */ + mockLocalCase(qkviewName = '.*qkview_telemetry_.*.tar.qkview', options = {}) { + return { + local: { + createQkview: this.local.mockCreateQkview(qkviewName, testUtil.deepCopy(options)), + deviceInfo: this.local.mockGetDeviceInfo(testUtil.deepCopy(options)), + removeQkview: this.local.mockRemoveFile(qkviewName, testUtil.deepCopy(options)) + } + }; + } + + /** + * Mock all methods that applicable for the remote device + * + * @param {string} [qkviewName] + * @property {NockMockOptions} [options] + * + * @returns {object} stubs + */ + mockRemoteCase(qkviewName = '.*qkview_telemetry_.*.tar.qkview', options = {}) { + const stubs = { + local: { + deviceInfo: this.local.mockGetDeviceInfo(testUtil.deepCopy(options)), + getMD5sum: this.local.mockGetMD5sum(qkviewName, testUtil.deepCopy(options)), + removeQkview: this.local.mockRemoveFile(qkviewName, testUtil.deepCopy(options)) + }, + remote: { + createQkview: this.remote.mockCreateQkview(qkviewName, testUtil.deepCopy(options)), + downloadQkview: this.remote.mockDownloadFile(qkviewName, testUtil.deepCopy(options)), + deviceInfo: this.remote.mockGetDeviceInfo(testUtil.deepCopy(options)), + getMD5sum: this.remote.mockGetMD5sum(qkviewName, testUtil.deepCopy(options)), + removeQkview: this.remote.mockRemoveFile(qkviewName, testUtil.deepCopy(options)) + } + }; + stubs.local.removeQkview.removePath.disable(); + return stubs; + } +} + +/** @returns {string} file name with .md5sum ext. */ +function wrapMD5(fpath) { + return `${fpath}.md5sum`; +} + +module.exports = { + DeviceApiMock, + IHealthApiMock, + QkviewManagerMock +}; + +/** + * @typedef {object} Credentials + * @property {string} username + * @property {string} password + * @property {string} token + */ +/** + * @typedef {object} NockMockOptions + * @property {boolean} [optionally = false] + * @property {number} [replyTimes = 1] + */ diff --git a/test/unit/ihealth/api/qkviewDiagnostics.json b/test/unit/ihealth/api/qkviewDiagnostics.json new file mode 100644 index 00000000..b1f73820 --- /dev/null +++ b/test/unit/ihealth/api/qkviewDiagnostics.json @@ -0,0 +1,88 @@ +{ + "sha1": "830daac4f548464fbada533075524bfa", + "version": { + "version": "17.1.0", + "product": "BIG-IP", + "built": "230823101811", + "edition": "Point Release 3" + }, + "diagnostics": { + "diagnostic": [ + { + "name": "D00", + "output": [], + "run_data": { + "h_importance": "HIGH", + "match": true + }, + "results": { + "h_action": "For additional information, refer to the linked article.", + "h_name": "D00", + "solution": [ + { + "id": "K00", + "value": "https://my.f5.com/manage/s/article/K00" + } + ], + "h_cve_ids": [ + "CVE-XXXX-YYYYY" + ], + "h_header": "BIG-IP vulnerability CVE-XXXX-YYYYY", + "h_summary": "BIG-IP vulnerability CVE-XXXX-YYYYY description." + }, + "fixedInVersions": { + "version": [ + { + "major": 99, + "minor": 98, + "maintenance": 96, + "point": 2, + "fix": "" + }, + { + "major": 99, + "minor": 98, + "maintenance": 97, + "point": 1, + "fix": "" + } + ] + } + }, + { + "name": "D01", + "output": [], + "run_data": { + "h_importance": "HIGH", + "match": true + }, + "results": { + "h_action": "For additional information, refer to the linked article.", + "h_name": "D01", + "solution": [ + { + "id": "K01", + "value": "https://my.f5.com/manage/s/article/K01" + } + ], + "h_cve_ids": [ + "CVE-XXXX-YYYYY" + ], + "h_header": "BIG-IP vulnerability CVE-XXXX-YYYYY", + "h_summary": "BIG-IP vulnerability CVE-XXXX-YYYYY description." + }, + "fixedInVersions": { + "version": [] + } + } + ], + "hit_count": 37, + "miss_count": 210, + "filter": "hit" + }, + "system_information": { + "bigip_chassis_serial_num": "830daac4-f548-464f-bada-533075524bfa", + "hostname": "bigip-ts-small.localdomain", + "platform": "Z100" + } +} \ No newline at end of file diff --git a/test/unit/ihealth/api/qkviewTests.js b/test/unit/ihealth/api/qkviewTests.js new file mode 100644 index 00000000..e60b03bf --- /dev/null +++ b/test/unit/ihealth/api/qkviewTests.js @@ -0,0 +1,217 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../../shared/restoreCache')(); + +const os = require('os'); +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const BigIpApiMock = require('../../shared/bigipAPIMock'); +const { DeviceApiMock, QkviewManagerMock } = require('./mocks'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); +const testUtil = require('../../shared/util'); + +const DeviceAPI = sourceCode('src/lib/ihealth/api/device'); +const logger = sourceCode('src/lib/logger'); +const QkviewMgr = sourceCode('src/lib/ihealth/api/qkview'); + +moduleCache.remember(); + +describe('iHealth / API / Qkview', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + const remotehost = 'remotehost'; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + stubs.default.coreStub({ logger: true }); + }); + + afterEach(() => { + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('should throw error on invalid params', () => { + assert.throws(() => new QkviewMgr(), /local device should be an instance of DeviceAPI/); + assert.throws(() => new QkviewMgr(null), /local device should be an instance of DeviceAPI/); + assert.throws(() => new QkviewMgr( + new DeviceAPI(localhost, { logger }) + ), /remote device should be an instance of DeviceAPI/); + assert.throws(() => new QkviewMgr( + new DeviceAPI(localhost, { logger }), + null + ), /remote device should be an instance of DeviceAPI/); + assert.throws(() => new QkviewMgr( + new DeviceAPI(localhost, { logger }), + new DeviceAPI(localhost, { logger }), + { downloadFolder: '' } + ), /download folder should be a non-empty collection/); + }); + + it('should create a new instance', () => { + const qkview = new QkviewMgr( + new DeviceAPI(localhost, { logger }), + new DeviceAPI('localhost2', { logger, credentials: { token: 'token' } }), + { + downloadFolder: 'test' + } + ); + + assert.instanceOf(qkview.local, DeviceAPI); + assert.deepStrictEqual(qkview.local.host, localhost); + assert.instanceOf(qkview.remote, DeviceAPI); + assert.deepStrictEqual(qkview.remote.host, 'localhost2'); + assert.deepStrictEqual(qkview.downloadFolder, 'test'); + }); + }); + + describe('.removeLocalFile()', () => { + const fileName = 'qkview_telemetry'; + let qkview; + let removeStub; + + beforeEach(() => { + const bigip = new BigIpApiMock(); + bigip.addPasswordlessUser(defaultUser); + removeStub = bigip.mockRemovePath(fileName); + + qkview = new QkviewMgr( + new DeviceAPI(localhost, { logger }), + new DeviceAPI(localhost, { logger }), + { downloadFolder: 'test' } + ); + }); + + it('should remove file from the local device', async () => { + await qkview.removeLocalFile(fileName); + assert.deepStrictEqual(removeStub.stub.callCount, 1); + }); + + it('should ignore errors when unable to remove file', async () => { + removeStub.stub.callsFake(() => [404, undefined, 'error message']); + await qkview.removeLocalFile(fileName); + assert.deepStrictEqual(removeStub.stub.callCount, 1); + }); + }); + + describe('.generateQkview()', () => { + const qkviewName = '.*qkview_telemetry_.*.tar.qkview'; + const qkviewRegExp = new RegExp(qkviewName); + + describe('local device', () => { + const downloadFolder = os.tmpdir(); + let qkviewMgr; + + beforeEach(() => { + const bigip = new BigIpApiMock(); + bigip.addPasswordlessUser(defaultUser); + + const qkviewMock = new QkviewManagerMock(new DeviceApiMock(bigip)); + const qkviewStubs = qkviewMock.mockLocalCase(qkviewName, { dir: downloadFolder }); + qkviewStubs.local.deviceInfo.deviceInfo.interceptor.times(2); + qkviewStubs.local.removeQkview.remove(); + + qkviewMgr = new QkviewMgr( + new DeviceAPI(localhost, { logger }), + new DeviceAPI(localhost, { logger }), + { downloadFolder } + ); + }); + + it('should collect qkview from local device', async () => { + const localQkview = await qkviewMgr.generateQkview(); + assert.isTrue(qkviewRegExp.test(localQkview)); + }); + }); + + describe('remote device', () => { + const connection = { + port: 8105, + protocol: 'https' + }; + const credentials = { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + }; + const downloadFolder = os.tmpdir(); + let qkviewMgr; + let qkviewStubs; + + beforeEach(() => { + const localBigIp = new BigIpApiMock(); + localBigIp.addPasswordlessUser(defaultUser); + + const remoteBigIp = new BigIpApiMock(remotehost, { + port: connection.port, + protocol: connection.protocol + }); + remoteBigIp.mockAuth(credentials.username, credentials.passphrase); + + const qkviewMock = new QkviewManagerMock( + new DeviceApiMock(localBigIp), + new DeviceApiMock(remoteBigIp) + ); + + qkviewStubs = qkviewMock.mockRemoteCase(qkviewName, { dir: downloadFolder }); + qkviewMgr = new QkviewMgr( + new DeviceAPI(localhost, { logger }), + new DeviceAPI(remotehost, { + connection: testUtil.deepCopy(connection), + credentials: testUtil.deepCopy(credentials), + logger + }), + { downloadFolder } + ); + }); + + it('should collect qkview from local device', async () => { + assert.match(await qkviewMgr.generateQkview(), qkviewRegExp); + }); + + it('should not fail when unable to remove qkview file from the remote device', async () => { + qkviewStubs.remote.removeQkview.removePath.stub.callsFake(() => [200, 'error']); + assert.match(await qkviewMgr.generateQkview(), qkviewRegExp); + }); + + it('should fail when MD5 sums do no match', async () => { + qkviewStubs.local.removeQkview.removePath.interceptor.optionally(false); + qkviewStubs.remote.getMD5sum.shell.stub.returns([200, 'md5sum md5sum']); + await assert.isRejected(qkviewMgr.generateQkview(), /MD5 sum.* !== .*/); + }); + + it('should try to remove local qkview when unable to download', async () => { + qkviewStubs.local.removeQkview.removePath.interceptor.optionally(false); + qkviewStubs.remote.downloadQkview.downloadFile.stub.returns([ + 404, undefined, undefined, 'error' + ]); + qkviewStubs.local.getMD5sum.remove(); + qkviewStubs.remote.getMD5sum.remove(); + await assert.isRejected(qkviewMgr.generateQkview(), /downloadFileFromDevice: HTTP Error: 404/); + }); + }); + }); +}); diff --git a/test/unit/ihealth/helpers.js b/test/unit/ihealth/helpers.js new file mode 100644 index 00000000..91660167 --- /dev/null +++ b/test/unit/ihealth/helpers.js @@ -0,0 +1,323 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const os = require('os'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); + +const localhost = 'localhost'; +const oneHour = 60 * 60 * 1000; // 1h in ms; +const now = Date.now(); +const dayOfWeek = { + 0: 'Sunday', + 1: 'Monday', + 2: 'Tuesday', + 3: 'Wednesday', + 4: 'Thursday', + 5: 'Friday', + 6: 'Saturday', + 7: 'Sunday' +}; +let userID = 0; + +function getHM(date) { + return `${date.getHours()}:${date.getMinutes()}`; +} + +function makeProxy({ full = false, auth = false, userOnly = false } = {}) { + let ret = { host: 'remoteproxy.host' }; + if (full) { + ret = Object.assign(ret, { + protocol: 'https', + port: 443, + allowSelfSignedCert: true, + enableHostConnectivityCheck: false + }); + } + if (auth || userOnly) { + userID += 1; + ret.username = `test_user_${userID}`; + if (!userOnly) { + ret.passphrase = { + cipherText: `test_passphrase_${userID}` + }; + } + } + return ret; +} + +function getTimeWindow(startd) { + return [ + new Date(startd + oneHour * 2), + new Date(startd + oneHour * 4) + ]; +} + +function getNextExecWindow(frequency, forward = 0) { + let startd; + if (frequency === 'daily') { + startd = (new Date(now + forward * oneHour * 24)); + } else if (frequency === 'weekly') { + // set for tomorrow + startd = (new Date(now + 24 * oneHour + forward * 7 * oneHour * 24)); + } else if (frequency === 'monthly') { + // set for tomorrow + startd = new Date(now + 24 * oneHour); + + if (forward) { + const originDay = startd.getUTCDate(); + startd.setDate(1); + startd.setMonth(startd.getMonth() + forward + 1); + startd.setDate(0); + + if (startd.getDate() > originDay) { + startd.setDate(originDay); + } + } + } + const tw = getTimeWindow(startd.getTime()); + tw[0].setUTCMilliseconds(0); + tw[1].setUTCMilliseconds(0); + + return tw; +} + +function ihealthPoller({ + dayNo = false, + dayStr = false, + downloadFolder = os.tmpdir(), + enable = true, + frequency = 'daily', + proxy = undefined, + trace = false +} = {}) { + const tw = getNextExecWindow(frequency); + const interval = { + frequency, + timeWindow: { + start: getHM(tw[0]), + end: getHM(tw[1]) + } + }; + + if (frequency === 'weekly') { + if (dayNo) { + interval.day = tw[0].getDay(); + } + if (dayStr) { + interval.day = dayOfWeek[tw[0].getDay()]; + } + } else if (frequency === 'monthly') { + interval.day = tw[0].getDate(); + } + + userID += 1; + const ret = dummies.declaration.ihealthPoller.minimal.decrypted({ + enable, + trace, + username: `test_user_${userID}`, + passphrase: { + cipherText: `test_passphrase_${userID}` + }, + downloadFolder, + interval + }); + + if (proxy) { + ret.proxy = makeProxy(proxy); + } + + return ret; +} + +function system(options = {}) { + const ret = dummies.declaration.system.minimal.decrypted(); + [ + 'allowSelfSignedCert', + 'enable', + 'host', + 'port', + 'protocol', + 'trace' + ].forEach((prop) => { + if (typeof options[prop] !== 'undefined') { + ret[prop] = options[prop]; + } + }); + + if (options.username === true || options.passphrase === true) { + userID += 1; + ret.username = `test_user_${userID}`; + } + if (options.passphrase === true) { + ret.passphrase = { + cipherText: `test_passphrase_${userID}` + }; + } + return ret; +} + +function checkBigIpRequests(declaration, spies) { + // ignore secrets encryption requests + const secretsURI = '/mgmt/tm/ltm/auth/radius-server'; + const host = declaration.system.host || localhost; + + let allowSelfSignedCert = declaration.system.allowSelfSignedCert; + if (typeof allowSelfSignedCert === 'undefined') { + allowSelfSignedCert = false; + } + + const props = { + strictSSL: !allowSelfSignedCert + }; + + let numbOfRequests = 0; + const numOfChecks = Object.assign({}, props); + Object.keys(numOfChecks).forEach((key) => { + numOfChecks[key] = 0; + }); + + Object.entries(spies).forEach(([key, spy]) => { + if (spy.callCount !== 0) { + spy.args.forEach((args) => { + if (args[0].uri.includes(host) && !args[0].uri.includes(secretsURI)) { + numbOfRequests += 1; + Object.entries(props).forEach(([name, expected]) => { + const actual = args[0][name]; + assert.deepStrictEqual(actual, expected, `request.${key} should use ${name} = ${expected}, got ${actual}`); + numOfChecks[name] += 1; + }); + } + }); + } + }); + + if (numbOfRequests > 0) { + Object.keys(numOfChecks).forEach((key) => { + assert.isAbove(numOfChecks[key], 0); + }); + } +} + +function checkIHealthRequests(declaration, spies) { + const host = '.f5.com'; + const props = { + proxy: undefined, + strictSSL: true + }; + if (declaration.ihealthPoller.proxy) { + const proxyDecl = declaration.ihealthPoller.proxy; + if (typeof proxyDecl.allowSelfSignedCert === 'boolean') { + props.strictSSL = !proxyDecl.allowSelfSignedCert; + } + if (proxyDecl.username) { + props.proxy = proxyDecl.username; + if (proxyDecl.passphrase) { + props.proxy = `${props.proxy}:${proxyDecl.passphrase.cipherText}`; + } + props.proxy = `${props.proxy}@`; + } + props.proxy = `${proxyDecl.protocol || 'http'}://${props.proxy || ''}${proxyDecl.host}:${proxyDecl.port || 80}`; + } + + let numbOfRequests = 0; + const numOfChecks = Object.assign({}, props); + Object.keys(numOfChecks).forEach((key) => { + numOfChecks[key] = 0; + }); + + Object.entries(spies).forEach(([key, spy]) => { + if (spy.callCount !== 0) { + spy.args.forEach((args) => { + if (args[0].uri.includes(host)) { + numbOfRequests += 1; + Object.entries(props).forEach(([name, expected]) => { + const actual = args[0][name]; + assert.deepStrictEqual(actual, expected, `request.${key} should use ${name} = ${expected}, got ${actual}`); + numOfChecks[name] += 1; + }); + } + }); + } + }); + + if (numbOfRequests > 0) { + Object.keys(numOfChecks).forEach((key) => { + assert.isAbove(numOfChecks[key], 0); + }); + } +} + +function attachPoller(pollerConfig, systemConfig) { + if (!systemConfig) { + systemConfig = system(); + } + + systemConfig.iHealthPoller = 'ihealthPoller'; + + return { + ihealthPoller: pollerConfig, + system: systemConfig + }; +} + +function getDeclaration({ + downloadFolder = undefined, + enable = true, + intervalConf = {}, + proxyConf = {}, + systemAuthConf = {}, + systemConf = {}, + trace = false +} = {}) { + return attachPoller( + ihealthPoller( + Object.assign( + { + downloadFolder, + enable, + proxy: proxyConf.value, + trace + }, + intervalConf.value + ) + ), + system( + Object.assign( + { + enable, + trace + }, + systemConf.value || {}, + systemAuthConf.value || {} + ) + ) + ); +} + +module.exports = { + attachPoller, + checkBigIpRequests, + checkIHealthRequests, + getDeclaration, + getNextExecWindow, + ihealthPoller, + makeProxy, + system +}; diff --git a/test/unit/ihealth/pollerMock.js b/test/unit/ihealth/pollerMock.js new file mode 100644 index 00000000..cf4d9f6c --- /dev/null +++ b/test/unit/ihealth/pollerMock.js @@ -0,0 +1,224 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const { + DeviceApiMock, + IHealthApiMock, + QkviewManagerMock +} = require('./api/mocks'); +const sourceCode = require('../shared/sourceCode'); + +const utilMisc = sourceCode('src/lib/utils/misc'); + +const defaultUser = 'admin'; +const localhost = 'localhost'; + +class PollerMock { + /** + * @param {IHealthConfig} ihealth + * @param {DeviceConfig} device + */ + constructor(ihealth, device) { + const httpMockOptions = { replyTimes: Infinity }; + + this.ihealth = { + inst: new IHealthApiMock() + }; + this.ihealth.auth = this.ihealth.inst.mockAuth( + ihealth.credentials.username, + ihealth.credentials.password, + httpMockOptions + ); + this.ihealth.qkviewDiag = this.ihealth.inst.mockQkviewDiagnostics( + undefined, + httpMockOptions + ); + this.ihealth.qkviewReport = this.ihealth.inst.mockQkviewReport(httpMockOptions); + this.ihealth.qkviewUpload = this.ihealth.inst.mockQkviewUpload('qkview.*', httpMockOptions); + + if (!device.connection.host || device.connection.host === localhost) { + // remote device case + this.localBigIp = { + inst: new BigIpApiMock(localhost, { + port: device.connection.port, + protocol: device.connection.protocol + }) + }; + this.localBigIp.inst.addPasswordlessUser(device.credentials.username || defaultUser); + this.qkviewMock = { + inst: new QkviewManagerMock( + new DeviceApiMock(this.localBigIp.inst) + ) + }; + this.qkviewMock.stubs = this.qkviewMock.inst.mockLocalCase(undefined, Object.assign({ + dir: ihealth.downloadFolder + }, httpMockOptions)); + } else { + // remote device case + this.localBigIp = { + inst: new BigIpApiMock() + }; + this.localBigIp.inst.addPasswordlessUser(defaultUser); + + this.remoteBigIp = { + inst: new BigIpApiMock(device.connection.host, { + port: device.connection.port, + protocol: device.connection.protocol + }) + }; + this.remoteBigIp.auth = this.remoteBigIp.inst.mockAuth( + device.credentials.username, + device.credentials.password, + httpMockOptions + ); + this.qkviewMock = { + inst: new QkviewManagerMock( + new DeviceApiMock(this.localBigIp.inst), + new DeviceApiMock(this.remoteBigIp.inst) + ) + }; + this.qkviewMock.stubs = this.qkviewMock.inst.mockRemoteCase(undefined, Object.assign({ + dir: ihealth.downloadFolder + }, httpMockOptions)); + } + + const createQkviewStub = this.qkviewMock.stubs.local.createQkview + || this.qkviewMock.stubs.remote.createQkview; + + createQkviewStub.cmdFiles = {}; + + createQkviewStub.dacli.createTask.stub.callsFake((_taskId, reqBody) => { + createQkviewStub.cmdFiles[_taskId] = reqBody.utilCmdArgs.split('../..')[1]; + return [200, { _taskId }]; + }); + createQkviewStub.dacli.pollTaskResult.stub.callsFake((taskId) => { + const qkviewFile = createQkviewStub.cmdFiles[taskId]; + assert.isDefined(qkviewFile, 'qkviewFile should be defined'); + utilMisc.fs.writeFileSync(qkviewFile, 'qkviewData'); + return [200, { _taskState: 'COMPLETED' }]; + }); + } + + /** Stub for obtaining Qkview diagnostics routine */ + getQkviewDiagStub() { + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.ihealth.qkviewDiag.stub.callsFake((data, template) => (customStub() + ? [200, template] + : [500, '', 'qkview report error'])); + return customStub; + } + + /** Stub for generating Qkview routine */ + getQkviewGenStub() { + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + const createQkviewStub = this.qkviewMock.stubs.local.createQkview + || this.qkviewMock.stubs.remote.createQkview; + + createQkviewStub.dacli.pollTaskResult.stub.callsFake((taskId) => { + const qkviewFile = createQkviewStub.cmdFiles[taskId]; + assert.isDefined(qkviewFile, 'qkviewFile should be defined'); + utilMisc.fs.writeFileSync(qkviewFile, 'qkviewData'); + + return [200, { _taskState: customStub() ? 'COMPLETED' : 'FAILED' }]; + }); + return customStub; + } + + /** Stub for obtaining Qkview report routine */ + getQkviewReportStub() { + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.ihealth.qkviewReport.stub.callsFake((data, template) => { + if (!customStub()) { + template.processing_status = 'ERROR'; + template.processing_messages = 'qkview processing error'; + } + return [200, template]; + }); + return customStub; + } + + /** Stub for Qkview upload routine */ + getQkviewUploadStub() { + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.ihealth.qkviewUpload.stub.callsFake((data, template) => (customStub() + ? [303, template] + : [500, '', 'qkview upload error'])); + return customStub; + } + + /** Stub for local Qkview removal routine */ + getRemoveLocalFileStub() { + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.qkviewMock.stubs.local.removeQkview.removePath.stub.callsFake(() => (customStub() + ? [200, ''] + : [500, '', 'remove local qkview error'])); + return customStub; + } +} + +module.exports = PollerMock; + +/** + * @typedef {object} DeviceConfig + * @property {object} connection + * @property {string} [connection.host] + * @property {number} [connection.port] + * @property {string} [connection.protocol] + * @property {object} credentials + * @property {string} [credentials.username] + * @property {string} [credentials.password] + */ +/** + * @typedef {object} IHealthCredentials + * @property {string} username + * @property {string} password + */ +/** + * @typedef {object} IHealthConfig + * @property {IHealthCredentials} credentials + * @property {string} downloadFolder + */ diff --git a/test/unit/ihealth/pollerTests.js b/test/unit/ihealth/pollerTests.js new file mode 100644 index 00000000..20ca9839 --- /dev/null +++ b/test/unit/ihealth/pollerTests.js @@ -0,0 +1,1022 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-constant-condition, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const os = require('os'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const helpers = require('./helpers'); +const PollerMock = require('./pollerMock'); +const qkviewDiag = require('./api/qkviewDiagnostics.json'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const IHealthPoller = sourceCode('src/lib/ihealth/poller'); +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); + +moduleCache.remember(); + +describe('iHealth / Poller ', () => { + const downloadFolder = os.tmpdir(); + const remotehost = 'remotehost.remotedonmain'; + let appEvents; + let configWorker; + let coreStub; + let declaration; + let fakeClock; + let pollerDestroytub; + let pollerStartStub; + let pollerStopStub; + let reports; + let requestSpies; + let service; + + function createPoller(demo = false) { + const proxy = makeManagerProxy(); + const poller = new IHealthPoller(proxy.proxy, { + demo, + logger: coreStub.logger.logger.getChild('TestPoller') + }); + return { + poller, + proxy + }; + } + + function createPollerMock(decl) { + return new PollerMock( + { + credentials: { + username: decl.ihealthPoller.username, + password: decl.ihealthPoller.passphrase.cipherText + }, + downloadFolder: decl.ihealthPoller.downloadFolder + }, + { + connection: { + host: decl.system.host, + port: decl.system.port, + protocol: decl.system.protocol + }, + credentials: { + username: decl.system.username, + password: decl.system.passphrase + ? decl.system.passphrase.cipherText + : undefined + } + } + ); + } + + async function forwardClock(time, cb, repeat = 1, delay = 1) { + if (!cb) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + return; + } + + while (true) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + try { + if (await cb()) { + break; + } + } catch (error) { + // igonre + } + } + } + + function getTimeStep() { + let step = 1; + if (declaration) { + if (declaration.ihealthPoller.interval.frequency === 'monthly') { + step = 48; + } + if (declaration.ihealthPoller.interval.frequency === 'weekly') { + step = 24; + } + } + return step * 60 * 60 * 1000; + } + + function makeManagerProxy() { + const proxy = { + config: { + config: null, + decrypted: false + }, + proxy: {}, + reports: [], + storage: null + }; + Object.defineProperties(proxy.proxy, { + cleanupConfig: { + value: sinon.stub() + }, + getConfig: { + value: sinon.stub() + }, + getStorage: { + value: sinon.stub() + }, + qkviewReport: { + value: sinon.stub() + }, + saveStorage: { + value: sinon.stub() + } + }); + proxy.proxy.cleanupConfig.callsFake(() => { + proxy.config.config = null; + proxy.config.decrypted = false; + }); + proxy.proxy.getConfig.callsFake(async (poller, decrypt = false) => { + if (proxy.config.config === null) { + proxy.config.config = (await (new Promise((resolve) => { + service.emitAsync('config.getConfig', resolve, { + class: 'Telemetry_iHealth_Poller' + }); + })))[0]; + } + if (decrypt && !proxy.config.decrypted) { + proxy.config.config = (await (new Promise((resolve, reject) => { + service.emitAsync('config.decrypt', testUtil.deepCopy(proxy.config.config), (error, decrypted) => { + if (error) { + reject(error); + } else { + resolve(decrypted); + } + }); + }))); + } + return testUtil.deepCopy(proxy.config.config); + }); + proxy.proxy.getStorage.callsFake(() => proxy.storage); + proxy.proxy.qkviewReport.callsFake((poller, report) => { + proxy.reports.push(report); + reports.push(report); + }); + proxy.proxy.saveStorage.callsFake((poller, data) => { + proxy.storage = data; + }); + + return proxy; + } + + function processDeclaration(decl) { + return configWorker.processDeclaration( + dummies.declaration.base.decrypted(decl) + ); + } + + function verifyReport(report) { + assert.deepStrictEqual(report.diagnostics, qkviewDiag, 'should match expected diagnostics'); + assert.isTrue(report.status.done); + assert.isFalse(report.status.error); + } + + function verifyReports() { + reports.map(verifyReport); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + declaration = null; + fakeClock = null; + reports = []; + requestSpies = testUtil.requestSpies(); + service = new SafeEventEmitter(); + + pollerDestroytub = sinon.stub(IHealthPoller.prototype, 'destroy'); + pollerDestroytub.callThrough(); + pollerStartStub = sinon.stub(IHealthPoller.prototype, 'start'); + pollerStartStub.callThrough(); + pollerStopStub = sinon.stub(IHealthPoller.prototype, 'stop'); + pollerStopStub.callThrough(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + appEvents.register(service, 'test', [ + 'config.decrypt', + 'config.getConfig' + ]); + + await coreStub.startServices(); + await coreStub.configWorker.configWorker.load(); + await coreStub.utilMisc.fs.promise.mkdir(downloadFolder); + + assert.isEmpty(coreStub.logger.messages.error); + coreStub.logger.removeAllMessages(); + }); + + afterEach(async () => { + if (fakeClock) { + fakeClock.stub.restore(); + } + await coreStub.destroyServices(); + + testUtil.nockCleanup(); + sinon.restore(); + + verifyReports(); + + if (declaration) { + helpers.checkBigIpRequests(declaration, requestSpies); + helpers.checkIHealthRequests(declaration, requestSpies); + } + }); + + describe('constructor', () => { + it('invalid args', () => { + assert.throws(() => new IHealthPoller(undefined, {}), /manager should be neither null or undefined/); + assert.throws(() => new IHealthPoller({}, {}), /logger should be an instance of Logger/); + assert.throws(() => new IHealthPoller({ test: true }, {}), /logger should be an instance of Logger/); + assert.throws(() => new IHealthPoller({ test: true }, { + logger: coreStub.logger.logger, + demo: null + }), /demo should be a boolean/); + }); + }); + + describe('configuration variations', () => { + const combinations = testUtil.product( + [ + { + name: 'regular', + value: false + }, + { + name: 'demo', + value: true + } + ], + // interval + testUtil.smokeTests.filter([ + { + name: 'daily', + value: { frequency: 'daily' } + }, + { + name: 'weekly (day number)', + value: { frequency: 'weekly', dayNo: true } + }, + { + name: 'weekly (day name)', + value: { frequency: 'weekly', dayStr: true } + }, + { + name: 'monthly', + value: { frequency: 'monthly' } + } + ]), + // system auth + testUtil.smokeTests.filter([ + { + name: 'system without user', + value: undefined + }, + { + name: 'system with user and passphrase', + value: { username: true, passphrase: true } + } + ]), + // system connection + testUtil.smokeTests.filter([ + { + name: 'localhost system', + value: undefined + }, + { + name: 'remote system with non default config', + value: { + host: remotehost, + allowSelfSignedCert: true, + port: 8889, + protocol: 'https' + } + } + ]) + ); + + combinations.forEach(([demoConf, intervalConf, systemAuthConf, systemConf]) => describe(`type = ${demoConf.name}, interval = ${intervalConf.name}, system = ${systemConf.name}, systemAuth = ${systemAuthConf.name}`, + () => { + if (systemConf.value && systemConf.value.host === remotehost + && !(systemAuthConf.value && systemAuthConf.value.passphrase) + ) { + return; + } + + let pollerStruct; + + function getDeclaration(enable = true, trace = false) { + return helpers.getDeclaration({ + downloadFolder, + enable, + intervalConf, + systemAuthConf, + systemConf, + trace + }); + } + + beforeEach(async () => { + declaration = getDeclaration(); + await processDeclaration(declaration); + + coreStub.logger.removeAllMessages(); + }); + + afterEach(async () => { + await pollerStruct.poller.destroy(); + }); + + if (demoConf.value) { + it('should return info', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + assert.deepStrictEqual(poller.info(), { + demoMode: true, + nextFireDate: 'not set', + prevFireDate: 'not set', + state: null, + terminated: false, + timeUntilNextExecution: 'not available' + }); + + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + // should start polling sycle right now, forward clock for 6 minues + await forwardClock(6 * 1000, () => { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + return true; + }); + + let info = poller.info(); + assert.isTrue(info.demoMode); + assert.isTrue(info.terminated); + assert.notDeepEqual(info.nextFireDate, 'not set'); + assert.deepStrictEqual(info.prevFireDate, 'not set'); + assert.isNumber(info.timeUntilNextExecution); + assert.isObject(info.state); + assert.lengthOf(info.state.history, 1); + assert.deepStrictEqual(info.state.stats, { + cycles: 1, + cyclesCompleted: 1, + qkviewCollectRetries: 0, + qkviewUploadRetries: 0, + qkviewsCollected: 1, + qkviewsUploaded: 1, + reportCollectRetries: 0, + reportsCollected: 1 + }); + + coreStub.logger.removeAllMessages(); + + await forwardClock(getTimeStep(), null, 100); + + info = poller.info(); + assert.isTrue(info.demoMode); + assert.isTrue(info.terminated); + assert.isObject(info.state); + assert.lengthOf(info.state.history, 1); + assert.deepStrictEqual(info.state.stats.cycles, 1, 'should run DEMO poller only once'); + assert.notIncludeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + }); + } else { + it('should return info', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + assert.deepStrictEqual(poller.info(), { + demoMode: false, + nextFireDate: 'not set', + prevFireDate: 'not set', + state: null, + terminated: false, + timeUntilNextExecution: 'not available' + }); + + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + await forwardClock(getTimeStep(), () => reports.length > 5); + + const info = poller.info(); + assert.isFalse(info.demoMode); + assert.isFalse(info.terminated); + assert.notDeepEqual(info.nextFireDate, 'not set'); + assert.notDeepEqual(info.prevFireDate, 'not set'); + assert.isNumber(info.timeUntilNextExecution); + assert.isObject(info.state); + }); + + it('should keep only 20 records in the history', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + await forwardClock(getTimeStep(), () => reports.length > 30); + + assert.isAbove(proxy.storage.stats.cycles, 30); + assert.lengthOf(proxy.storage.history, 20); + + const historyCopy = testUtil.deepCopy(proxy.storage.history); + const rlen = reports.length; + + await forwardClock(getTimeStep(), () => reports.length > (rlen + 5)); + + const idx = proxy.storage.history.findIndex((rec) => { + try { + assert.deepStrictEqual(historyCopy[historyCopy.length - 1], rec); + return true; + } catch (error) { + return false; + } + }); + + assert.isNumber(idx); + assert.isAbove(idx, 0); + assert.isBelow(idx, proxy.storage.history.length - 1); + }); + } + + it('should start and finish polling cycle(s)', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + const decryptCC = coreStub.deviceUtil.decrypt.callCount; + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return reports.length >= 1; + } + return reports.length > 2; + }); + + assert.isEmpty(coreStub.logger.messages.error); + // also it should decrypt config multiple times + assert.isAbove( + coreStub.deviceUtil.decrypt.callCount, + decryptCC, + 'should decrypt config multiple times' + ); + assert.deepStrictEqual(proxy.storage.version, '3.0'); + + if (demoConf.value) { + assert.deepStrictEqual(proxy.storage.history.length, 1); + assert.deepStrictEqual(proxy.storage.stats.cycles, 1); + assert.deepStrictEqual(proxy.storage.stats.cyclesCompleted, 1); + assert.deepStrictEqual(proxy.storage.stats.qkviewsCollected, 1); + assert.deepStrictEqual(proxy.storage.stats.qkviewsUploaded, 1); + assert.deepStrictEqual(proxy.storage.stats.reportsCollected, 1); + assert.deepStrictEqual(proxy.storage.stats.qkviewCollectRetries, 0); + assert.deepStrictEqual(proxy.storage.stats.qkviewUploadRetries, 0); + assert.deepStrictEqual(proxy.storage.stats.reportCollectRetries, 0); + } else { + assert.isAtLeast(proxy.storage.history.length, reports.length); + assert.isAtLeast(proxy.storage.stats.cycles, proxy.storage.history.length + 1); + assert.isAtLeast(proxy.storage.stats.cyclesCompleted, proxy.storage.history.length); + assert.isAtLeast(proxy.storage.stats.qkviewsCollected, proxy.storage.history.length); + assert.isAtLeast(proxy.storage.stats.qkviewsUploaded, proxy.storage.history.length); + assert.isAtLeast(proxy.storage.stats.reportsCollected, proxy.storage.history.length); + assert.isAtLeast(proxy.storage.stats.qkviewCollectRetries, 0); + assert.isAtLeast(proxy.storage.stats.qkviewUploadRetries, 0); + assert.isAtLeast(proxy.storage.stats.reportCollectRetries, 0); + + // for daily by default, in days + let min = 0; + let max = 3; + if (declaration.ihealthPoller.interval.frequency === 'weekly') { + min = 5; + max = 13; + } else if (declaration.ihealthPoller.interval.frequency === 'monthly') { + min = 20; + max = 35; + } + + for (let i = 1; i < proxy.storage.history.length; i += 1) { + const h1 = proxy.storage.history[i - 1]; + const h2 = proxy.storage.history[i]; + + const diff = h2.schedule - h1.schedule; + assert.isAtLeast(diff, min * 24 * 60 * 60 * 1000, `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be at least ${min}`); + assert.isAtMost(diff, max * 24 * 60 * 60 * 1000, `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be at most ${max}`); + } + } + }); + + it('should restore state', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + return true; + }, true); + + const storageCopy = testUtil.deepCopy(proxy.storage); + // scheduled, the storage updated + await poller.destroy(); + + coreStub.logger.removeAllMessages(); + proxy.storage = storageCopy; + await poller.start(); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, new RegExp(`Restoring poller to the state "${storageCopy.state.lastKnownState}". Cycle #1`)); + return true; + }, true); + }); + + it('should restore state when exec date is past due', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + let { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + return true; + }, true); + + // scheduled, the storage updated + const storageCopy = testUtil.deepCopy(proxy.storage); + await poller.destroy(); + + pollerStruct = createPoller(demoConf.value); + poller = pollerStruct.poller; + proxy = pollerStruct.proxy; + + // adjust clock for 100d back + storageCopy.state.execDate -= 100 * 24 * 60 * 60 * 1000; + proxy.storage = storageCopy; + proxy.storage.state.lastKnownState = 'WAITING'; + + coreStub.logger.removeAllMessages(); + await poller.start(); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Restoring poller to the state "PAST_DUE". Cycle #1/); + return true; + }, true); + + await testUtil.waitTill(() => { + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + return true; + }, true); + + assert.includeMatch(coreStub.logger.messages.debug, /Next execution is past due/); + assert.includeMatch( + coreStub.logger.messages.error, + /iHealth Poller cycle failed due task error[\s\S]*Polling execution date expired/gm + ); + }); + + it('should report the task is failed when unable to decrypt config', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /iHealth Poller cycle failed due task error[\s\S]*expected decrypt/); + return true; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + coreStub.logger.removeAllMessages(); + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + return true; + }); + } + }); + + it('should restart poller when main loop error caught', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + proxy.proxy.saveStorage.throws(new Error('expected save storage error')); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.error, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.error, /restart requested due erro[\s\S]*expected save storage error/); + } + return true; + }); + + if (!demoConf.value) { + coreStub.logger.removeAllMessages(); + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + return true; + }); + } + }); + + it('should ignore old version of storage struct', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(demoConf.value); + + let { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + return true; + }, true); + + // scheduled, the storage updated + const storageCopy = testUtil.deepCopy(proxy.storage); + await poller.destroy(); + + pollerStruct = createPoller(demoConf.value); + poller = pollerStruct.poller; + proxy = pollerStruct.proxy; + + // adjust clock for 100d back + storageCopy.state.execDate -= 100 * 24 * 60 * 60 * 1000; + proxy.storage = storageCopy; + proxy.storage.state.lastKnownState = 'waiting'; + proxy.storage.version = 'something'; + + coreStub.logger.removeAllMessages(); + await poller.start(); + + await testUtil.waitTill(() => { + assert.includeMatch(coreStub.logger.messages.debug, /Creating a new storage struct/); + return true; + }, true); + + await testUtil.waitTill(() => { + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + return true; + }, true); + }); + + it('should ignore qkview removal errors', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getRemoveLocalFileStub().returns(false); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Transitioning from step "SEND_REPORT" to "DONE"/); + return true; + }); + + assert.includeMatch(coreStub.logger.messages.debug, /localhost.*Unable to remove.*qkview[\s\S]*Bad status code: 500/); + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + }); + + it('should fail task when exceeded number of attempts to upload qkview', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewUploadStub().returns(false); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /iHealth Poller cycle failed due task error/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 1); + assert.isAtLeast(info.state.stats.qkviewUploadRetries, 5); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.deepStrictEqual(info.state.history[0].errorMsg, 'Error: Step "QKVIEW_UPLOAD" failed! Re-try allowed = false. Re-try attemps left 0 / 5.'); + }); + + it('should re-try task when unable to upload qkview', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewUploadStub() + .onFirstCall() + .returns(false) + .callThrough(); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Transitioning from step "SEND_REPORT" to "DONE"/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 1); + assert.deepStrictEqual(info.state.stats.qkviewUploadRetries, 1); + assert.deepStrictEqual(info.state.history[0].state, 'DONE'); + }); + + it('should fail task when exceeded number of attempts to get qkview diagnostics', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewDiagStub().returns(false); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /iHealth Poller cycle failed due task error/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 1); + assert.isAtLeast(info.state.stats.qkviewsUploaded, 1); + assert.isAtLeast(info.state.stats.reportCollectRetries, 30); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.deepStrictEqual(info.state.history[0].errorMsg, 'Error: Step "QKVIEW_REPORT" failed! Re-try allowed = false. Re-try attemps left 0 / 30.'); + }); + + it('should re-try task when unable to get qkview diagnostics', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewDiagStub() + .onFirstCall() + .returns(false) + .callThrough(); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Transitioning from step "SEND_REPORT" to "DONE"/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 1); + assert.isAtLeast(info.state.stats.qkviewsUploaded, 1); + assert.deepStrictEqual(info.state.stats.reportCollectRetries, 1); + assert.deepStrictEqual(info.state.history[0].state, 'DONE'); + }); + + it('should fail task when iHealth API returns error for qkview processing', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewReportStub().returns(false); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /iHealth Poller cycle failed due task error[\s\S]*F5 iHealth Service Error: qkview processing error/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 1); + assert.isAtLeast(info.state.stats.qkviewsUploaded, 1); + assert.deepStrictEqual(info.state.stats.reportCollectRetries, 0); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.deepStrictEqual(info.state.history[0].errorMsg, 'Error: F5 iHealth Service Error: qkview processing error'); + }); + + it('should fail task when exceeded number of attempts to generate qkview', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewGenStub().returns(false); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /iHealth Poller cycle failed due task error/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 0); + assert.isAtLeast(info.state.stats.qkviewCollectRetries, 5); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.deepStrictEqual(info.state.history[0].errorMsg, 'Error: Step "QKVIEW_GEN" failed! Re-try allowed = false. Re-try attemps left 0 / 5.'); + }); + + it('should re-try task when unable to generate qkview', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getQkviewGenStub() + .onFirstCall() + .returns(false) + .callThrough(); + + pollerStruct = createPoller(demoConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.debug, /Transitioning from step "SEND_REPORT" to "DONE"/); + return true; + }); + await forwardClock(getTimeStep(), () => { + if (demoConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (demoConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating DEMO poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.isAtLeast(info.state.stats.qkviewsCollected, 0); + assert.isAtLeast(info.state.stats.qkviewCollectRetries, 1); + assert.deepStrictEqual(info.state.history[0].state, 'DONE'); + }); + })); + }); +}); diff --git a/test/unit/ihealth/reportDiagnostics.json b/test/unit/ihealth/reportDiagnostics.json new file mode 100644 index 00000000..b8971160 --- /dev/null +++ b/test/unit/ihealth/reportDiagnostics.json @@ -0,0 +1,50 @@ +[ + { + "importance": "HIGH", + "action": "For additional information, refer to the linked article.", + "name": "D00", + "solution": [ + { + "id": "K00", + "value": "https://my.f5.com/manage/s/article/K00" + } + ], + "cveIds": [ + "CVE-XXXX-YYYYY" + ], + "header": "BIG-IP vulnerability CVE-XXXX-YYYYY", + "summary": "BIG-IP vulnerability CVE-XXXX-YYYYY description.", + "version": [ + { + "major": 99, + "minor": 98, + "maintenance": 96, + "point": 2, + "fix": "" + }, + { + "major": 99, + "minor": 98, + "maintenance": 97, + "point": 1, + "fix": "" + } + ] + }, + { + "importance": "HIGH", + "action": "For additional information, refer to the linked article.", + "name": "D01", + "solution": [ + { + "id": "K01", + "value": "https://my.f5.com/manage/s/article/K01" + } + ], + "cveIds": [ + "CVE-XXXX-YYYYY" + ], + "header": "BIG-IP vulnerability CVE-XXXX-YYYYY", + "summary": "BIG-IP vulnerability CVE-XXXX-YYYYY description." + } +] \ No newline at end of file diff --git a/test/unit/ihealth/serviceDemoTests.js b/test/unit/ihealth/serviceDemoTests.js new file mode 100644 index 00000000..c4464caa --- /dev/null +++ b/test/unit/ihealth/serviceDemoTests.js @@ -0,0 +1,1538 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-constant-condition, no-continue, no-restricted-syntax, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const os = require('os'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const helpers = require('./helpers'); +const PollerMock = require('./pollerMock'); +const reportDiag = require('./reportDiagnostics.json'); +const restAPIUtils = require('../restAPI/utils'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const assertLib = sourceCode('src/lib/utils/assert'); +const DataPipeline = sourceCode('src/lib/dataPipeline'); +const IHealthService = sourceCode('src/lib/ihealth'); +const IHealthPoller = sourceCode('src/lib/ihealth/poller'); +const RESTAPIService = sourceCode('src/lib/restAPI'); +const RestWorker = sourceCode('src/nodejs/restWorker'); + +moduleCache.remember(); + +describe('iHealth / iHealth DEMO Service', () => { + const downloadFolder = os.tmpdir(); + const ihURI = '/ihealthpoller'; + const localhost = 'localhost'; + const remotehost = 'remotehost.remotedonmain'; + let appEvents; + let configWorker; + let coreStub; + let dataPipeline; + let declaration; + let fakeClock; + let pollerDestroytub; + let pollerStartStub; + let pollerStopStub; + let reports; + let requestSpies; + let restAPI; + let restWorker; + let service; + + function createPollerMock(decl) { + return new PollerMock( + { + credentials: { + username: decl.ihealthPoller.username, + password: decl.ihealthPoller.passphrase.cipherText + }, + downloadFolder: decl.ihealthPoller.downloadFolder + }, + { + connection: { + host: decl.system.host, + port: decl.system.port, + protocol: decl.system.protocol + }, + credentials: { + username: decl.system.username, + password: decl.system.passphrase + ? decl.system.passphrase.cipherText + : undefined + } + } + ); + } + + async function forwardClock(time, cb) { + while (true) { + await fakeClock.clockForward(time, { repeat: 1, promisify: true, delay: 1 }); + try { + if (await cb()) { + break; + } + } catch (error) { + // igonre + } + } + } + + function getTimeStep() { + return 60 * 1000; + } + + function processDeclaration(decl, namespace) { + let promise; + if (namespace) { + promise = configWorker.processNamespaceDeclaration( + dummies.declaration.namespace.base.decrypted(decl), + namespace + ); + } else { + promise = configWorker.processDeclaration( + dummies.declaration.base.decrypted(decl) + ); + } + return Promise.all([ + appEvents.waitFor('ihealth.config.applied'), + appEvents.waitFor('restapi.config.applied'), + promise + ]); + } + + function sendRequest() { + return restAPIUtils.waitRequestComplete( + restWorker, + restAPIUtils.buildRequest.apply(restAPIUtils, arguments) + ); + } + + function verifyReport(report) { + assert.deepStrictEqual(report.data.diagnostics, reportDiag, 'should match expected diagnostics'); + assertLib.string(report.data.system.hostname, 'hostname'); + assertLib.string(report.data.system.ihealthLink, 'ihealthLink'); + assertLib.string(report.data.system.qkviewNumber, 'qkviewNumber'); + assertLib.object(report.data.telemetryServiceInfo, 'telemetryServiceInfo'); + assertLib.string(report.data.telemetryServiceInfo.cycleStart, 'telemetryServiceInfo.cycleStart'); + assertLib.string(report.data.telemetryServiceInfo.cycleEnd, 'telemetryServiceInfo.cycleEnd'); + assert.deepStrictEqual(report.data.telemetryEventCategory, 'ihealthInfo'); + assert.deepStrictEqual(report.type, 'ihealthInfo'); + } + + function verifyReports() { + reports.map(verifyReport); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + declaration = null; + fakeClock = null; + requestSpies = testUtil.requestSpies(); + + pollerDestroytub = sinon.stub(IHealthPoller.prototype, 'destroy'); + pollerDestroytub.callThrough(); + pollerStartStub = sinon.stub(IHealthPoller.prototype, 'start'); + pollerStartStub.callThrough(); + pollerStopStub = sinon.stub(IHealthPoller.prototype, 'stop'); + pollerStopStub.callThrough(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + restAPI = new RESTAPIService(restAPIUtils.TELEMETRY_URI_PREFIX); + restWorker = new RestWorker(); + restAPI.initialize(appEvents); + restWorker.initialize(appEvents); + + dataPipeline = new DataPipeline(); + dataPipeline.initialize(appEvents); + + service = new IHealthService(); + service.initialize(appEvents); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPollers, 0); + + await dataPipeline.start(); + assert.isTrue(dataPipeline.isRunning()); + + await service.start(); + assert.isTrue(service.isRunning()); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + await coreStub.startServices(); + await Promise.all([ + appEvents.waitFor('ihealth.config.applied'), + coreStub.configWorker.configWorker.load() + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPollers, 0); + assert.isEmpty(coreStub.logger.messages.error); + + coreStub.logger.removeAllMessages(); + + reports = []; + service.ee.on('report', (report) => reports.push(report)); + + await coreStub.utilMisc.fs.promise.mkdir(downloadFolder); + }); + + afterEach(async () => { + if (fakeClock) { + fakeClock.stub.restore(); + } + await dataPipeline.destroy(); + await restAPI.destroy(); + await service.destroy(); + await coreStub.destroyServices(); + + assert.isTrue(restAPI.isDestroyed()); + assert.isTrue(service.isDestroyed()); + + testUtil.nockCleanup(); + sinon.restore(); + + verifyReports(); + + if (declaration) { + helpers.checkBigIpRequests(declaration, requestSpies); + helpers.checkIHealthRequests(declaration, requestSpies); + } + }); + + it('should destroy demo pollers on service destroy', async () => { + declaration = helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + }); + const mainDeclaration = Object.assign({ + controls: dummies.declaration.controls.full.decrypted({ debug: true }) + }, declaration); + + await processDeclaration(mainDeclaration); + + const restOp = await sendRequest({ method: 'POST', path: `${ihURI}/system` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + await service.destroy(); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Poller "DEMO_f5telemetry_default::system::ihealthPoller" destroyed/gm + ); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO poller'); + }); + + it('should remove inactive demo pollers', async () => { + declaration = helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + }); + + await service.destroy(); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + await Promise.all([ + (async () => { + await service.start(); + })(), + forwardClock(getTimeStep(), () => service.isRunning()) + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO poller'); + + const mainDeclaration = Object.assign({ + controls: dummies.declaration.controls.full.decrypted({ debug: true }) + }, declaration); + + let done = false; + await Promise.all([ + (async () => { + await processDeclaration(mainDeclaration); + done = true; + })(), + forwardClock(getTimeStep(), () => done) + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO poller'); + + createPollerMock(declaration); + + done = false; + await Promise.all([ + (async () => { + const restOp = await sendRequest({ method: 'POST', path: `${ihURI}/system` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + done = true; + })(), + forwardClock(getTimeStep(), () => done) + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller'); + + done = false; + await Promise.all([ + (async () => { + let restOp; + while (!done) { + await testUtil.sleep(120 * 1000); + restOp = await sendRequest({ method: 'GET', path: `${ihURI}/system?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + done = restOp.body.states[0].terminated; + } + assert.deepStrictEqual(restOp.body.states[0].state.state.lastKnownState, 'DONE'); + })(), + forwardClock(getTimeStep(), () => done) + ]); + + assert.includeMatch( + coreStub.logger.messages.debug, + /DEMO_Poller.*Poller.*Terminating DEMO poller/ + ); + + coreStub.logger.removeAllMessages(); + + done = false; + await forwardClock(getTimeStep(), () => { + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have 0 DEMO poller'); + assert.includeMatch( + coreStub.logger.messages.debug, + /Removing DEMO iHealth Poller "DEMO_f5telemetry_default::system::ihealthPoller"/ + ); + return true; + }); + }); + + it('should be a debug endpoint only', async () => { + const mainDeclaration = Object.assign({ + controls: dummies.declaration.controls.full.decrypted({ debug: false }), + namespace: dummies.declaration.namespace.base.decrypted(helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + })) + }, helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + })); + + await processDeclaration(mainDeclaration); + + const requests = [ + { + methods: ['DELETE', 'GET'], uri: ihURI + }, + { + methods: ['DELETE', 'GET'], uri: `/namespace/namespace${ihURI}` + }, + { + methods: ['DELETE', 'GET', 'POST'], uri: `${ihURI}/system` + }, + { + methods: ['DELETE', 'GET', 'POST'], uri: `/namespace/namespace${ihURI}/system` + } + ]; + + for (const req of requests) { + for (const method of req.methods) { + const restOp = await sendRequest({ method, path: req.uri }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND, `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON, `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${req.uri}`, + message: 'Not Found' + }, `${method} ${req.uri}`); + } + } + + mainDeclaration.controls.debug = true; + await processDeclaration(mainDeclaration); + + for (const req of requests) { + for (const method of req.methods) { + const restOp = await sendRequest({ method, path: req.uri }); + assert.notDeepEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND, `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON, `${method} ${req.uri}`); + assert.notDeepEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${req.uri}`, + message: 'Not Found' + }, `${method} ${req.uri}`); + } + } + }); + + it('should not listen for requests when service destroyed', async () => { + const mainDeclaration = Object.assign({ + controls: dummies.declaration.controls.full.decrypted({ debug: true }), + namespace: dummies.declaration.namespace.base.decrypted(helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + })) + }, helpers.getDeclaration({ + downloadFolder, + enable: true, + intervalConf: { frequency: 'daily' }, + trace: false + })); + + await processDeclaration(mainDeclaration); + + const requests = [ + { + methods: ['GET', 'DELETE'], uri: ihURI + }, + { + methods: ['GET', 'DELETE'], uri: `/namespace/namespace${ihURI}` + }, + { + methods: ['GET', 'POST', 'DELETE'], uri: `${ihURI}/system` + }, + { + methods: ['GET', 'POST', 'DELETE'], uri: `/namespace/namespace${ihURI}/system` + } + ]; + + for (const req of requests) { + for (const method of req.methods) { + const restOp = await sendRequest({ method, path: req.uri }); + assert.oneOf(restOp.statusCode, [restAPIUtils.HTTP_CODES.OK, restAPIUtils.HTTP_CODES.CREATED], `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON, `${method} ${req.uri}`); + } + } + + await service.destroy(); + + for (const req of requests) { + for (const method of req.methods) { + const restOp = await sendRequest({ method, path: req.uri }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND, `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON, `${method} ${req.uri}`); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${req.uri}`, + message: 'Not Found' + }, `${method} ${req.uri}`); + } + } + }); + + describe('configuration variations', () => { + const combinations = testUtil.product( + // interval + testUtil.smokeTests.filter([ + { + name: 'daily', + value: { frequency: 'daily' } + }, + testUtil.smokeTests.ignore({ + name: 'weekly (day number)', + value: { frequency: 'weekly', dayNo: true } + }), + testUtil.smokeTests.ignore({ + name: 'weekly (day name)', + value: { frequency: 'weekly', dayStr: true } + }), + testUtil.smokeTests.ignore({ + name: 'monthly', + value: { frequency: 'monthly' } + }) + ]), + // proxy + testUtil.smokeTests.filter([ + { + name: 'no proxy', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'minimal and no auth', + value: { full: false } + }), + testUtil.smokeTests.ignore({ + name: 'minimal and user only', + value: { full: false, userOnly: true } + }), + testUtil.smokeTests.ignore({ + name: 'minimal and user and pass', + value: { full: false, auth: true } + }), + testUtil.smokeTests.ignore({ + name: 'all props no auth', + value: { full: true } + }), + testUtil.smokeTests.ignore({ + name: 'all props and user only', + value: { full: true, userOnly: true } + }), + testUtil.smokeTests.ignore({ + name: 'all props and user and pass', + value: { full: true, auth: true } + }) + ]), + // system auth + testUtil.smokeTests.filter([ + { + name: 'system without user', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'system with user', + value: { username: true } + }), + { + name: 'system with user and passphrase', + value: { username: true, passphrase: true } + } + ]), + // system connection + testUtil.smokeTests.filter([ + { + name: 'localhost system', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'localhost system (explicit)', + value: { + host: localhost + } + }), + testUtil.smokeTests.ignore({ + name: 'localhost system with non default config', + value: { + allowSelfSignedCert: true, + port: 8888, + protocol: 'https' + } + }), + testUtil.smokeTests.ignore({ + name: 'remote system', + value: { + host: remotehost + } + }), + { + name: 'remote system with non default config', + value: { + host: remotehost, + allowSelfSignedCert: true, + port: 8889, + protocol: 'https' + } + } + ]), + // namespace + [ + { + name: 'default', + value: undefined + }, + { + name: 'custom', + value: 'namespace' + } + ], + // enable + [ + { + name: 'enabled', + value: true + }, + { + name: 'disabled', + value: false + } + ] + ); + + combinations.forEach(([intervalConf, proxyConf, systemAuthConf, systemConf, namespaceConf, enableConf]) => describe(`interval = ${intervalConf.name}, proxy = ${proxyConf.name}, system = ${systemConf.name}, systemAuth = ${systemAuthConf.name}, namespace = ${namespaceConf.name}, enable = ${enableConf.name}`, + () => { + if (systemConf.value && systemConf.value.host === remotehost + && !(systemAuthConf.value && systemAuthConf.value.passphrase) + ) { + return; + } + + const traceNamePrefix = namespaceConf.value || 'f5telemetry_default'; + const uriPrefix = `${namespaceConf.value ? `/namespace/${namespaceConf.value}` : ''}${ihURI}`; + let expectedNumberOrActivePOllers; + let expectedNAPNs; + let mainDeclaration; + + function getDeclaration(enable = undefined) { + return helpers.getDeclaration({ + downloadFolder, + enable: typeof enable === 'undefined' ? enableConf.value : enable, + intervalConf, + proxyConf, + systemAuthConf, + systemConf, + trace: false + }); + } + + function getTestSystem() { + const root = namespaceConf.value ? mainDeclaration.namespace : mainDeclaration; + return { + system: root.enabledSystem, + ihealthPoller: root.enabledPoller + }; + } + + beforeEach(async () => { + mainDeclaration = { + controls: dummies.declaration.controls.full.decrypted({ debug: true }), + namespace: dummies.declaration.namespace.base.decrypted() + }; + + [mainDeclaration, mainDeclaration.namespace].forEach((ns, idx) => { + ns.consumer = dummies.declaration.consumer.default.decrypted({}); + + const disabledPair = getDeclaration(); + disabledPair.ihealthPoller.enable = false; + disabledPair.system.enable = false; + ns.disabledSystem = disabledPair.system; + ns.disabledPoller = disabledPair.ihealthPoller; + ns.disabledSystem.iHealthPoller = 'disabledPoller'; + + const enabledPair = getDeclaration(); + enabledPair.ihealthPoller.enable = true; + enabledPair.system.enable = true; + ns.enabledSystem = enabledPair.system; + ns.enabledPoller = enabledPair.ihealthPoller; + ns.enabledSystem.iHealthPoller = 'enabledPoller'; + + const uniqueNameDisabledPair = getDeclaration(); + uniqueNameDisabledPair.ihealthPoller.enable = false; + uniqueNameDisabledPair.system.enable = false; + ns[`disabledSystem${idx + 1}`] = uniqueNameDisabledPair.system; + ns[`disabledPoller${idx + 1}`] = uniqueNameDisabledPair.ihealthPoller; + ns[`disabledSystem${idx + 1}`].iHealthPoller = `disabledPoller${idx + 1}`; + + const uniqueNameEnabledPair = getDeclaration(); + uniqueNameEnabledPair.ihealthPoller.enable = true; + uniqueNameEnabledPair.system.enable = true; + ns[`enabledSystem${idx + 1}`] = uniqueNameEnabledPair.system; + ns[`enabledPoller${idx + 1}`] = uniqueNameEnabledPair.ihealthPoller; + ns[`enabledSystem${idx + 1}`].iHealthPoller = `enabledPoller${idx + 1}`; + }); + + expectedNumberOrActivePOllers = { + total: 4, + default: 2, + namespace: 2 + }; + + expectedNAPNs = namespaceConf.value + ? expectedNumberOrActivePOllers.namespace + : expectedNumberOrActivePOllers.default; + + expectedNumberOrActivePOllers.total = expectedNumberOrActivePOllers.default + + expectedNumberOrActivePOllers.namespace; + + await processDeclaration(mainDeclaration); + assert.isAbove(expectedNAPNs, 0); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + }); + + describe('GET /ihealthpoller/:system', () => { + it('should be able to get current state within provided namespace (no DEMO pollers)', async () => { + let restOp = await sendRequest({ method: 'GET', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs); + assert.lengthOf(restOp.body.states, expectedNAPNs); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs); + assert.lengthOf(restOp.body.states, expectedNAPNs); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOrActivePOllers.total); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + expectedNumberOrActivePOllers.total); + assert.lengthOf(restOp.body.states, expectedNumberOrActivePOllers.total); + } + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs); + assert.lengthOf(restOp.body.states, 0); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?demo=true&all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.lengthOf(restOp.body.states, 0); + + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOrActivePOllers.total); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + expectedNumberOrActivePOllers.total); + } + + // enabledSystem and disabledSystem is in use by 2 namespaces + // so for the defualt ?all=true returns all instances + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 1); + assert.lengthOf(restOp.body.states, 1); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 1); + assert.lengthOf(restOp.body.states, 0); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem?demo=true&all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.lengthOf(restOp.body.states, 0); + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 1); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, 2); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 2); + } + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 1); + assert.lengthOf(restOp.body.states, 1); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, 2); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 2); + assert.lengthOf(restOp.body.states, 2); + } + + const systemNames = [ + 'disabledSystem', + `disabledSystem${namespaceConf.value ? 2 : 1}` + ]; + const queryParams = [ + '', + 'demo=true', + 'all=true' + ]; + + for (const [systemName, firstParam, secondParam] of testUtil.product( + systemNames, + queryParams, + queryParams + )) { + if (firstParam === secondParam && firstParam) { + // skip dups; + continue; + } + const params = [firstParam, secondParam].filter((p) => p).join('&'); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${systemName}?${params}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 0); + assert.lengthOf(restOp.body.states, 0); + } + + for (const [firstParam, secondParam] of testUtil.product( + queryParams, + queryParams + )) { + if (firstParam === secondParam && firstParam) { + // skip dups; + continue; + } + const params = [firstParam, secondParam].filter((p) => p).join('&'); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem${namespaceConf.value ? 2 : 1}?${params}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 0); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 1); + assert.lengthOf(restOp.body.states, params.includes('demo') ? 0 : 1); + } + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + }); + + it('should return 404 when unable to find object by name', async () => { + let restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/nonExistingPoller` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${uriPrefix}/nonExistingPoller`, + message: 'Not Found' + }); + + restOp = await sendRequest({ method: 'GET', path: `/namespace/nonExistingNamespace/${ihURI}/nonExistingPoller` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry/namespace/nonExistingNamespace/${ihURI}/nonExistingPoller`, + message: 'Not Found' + }); + }); + }); + + describe('POST /ihealthpoller', () => { + it('should not allow send POST without a system name', async () => { + const restOp = await sendRequest({ method: 'POST', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.METHOD_NOT_ALLOWED, + error: 'Allowed methods: DELETE, GET', + message: 'Method Not Allowed' + }); + }); + }); + + describe('POST /ihealthpoller/:system', () => { + it('should return 404 when unable to find object by name', async () => { + const restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/nonExistingPoller` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${uriPrefix}/nonExistingPoller`, + message: 'Not Found' + }); + }); + + it('should be able to start DEMO pollers', async () => { + const pollers = [ + 'enabledSystem', + 'disabledSystem', + `enabledSystem${namespaceConf.value ? 2 : 1}`, + `disabledSystem${namespaceConf.value ? 2 : 1}` + ]; + + for (let i = 1; i <= pollers.length; i += 1) { + const pollerName = pollers[i - 1]; + + let restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.CREATED, + message: `DEMO poller "${traceNamePrefix}::${pollerName}::${pollerName.replace('System', 'Poller')}" created.` + }); + + assert.deepStrictEqual(service.numberOfDemoPollers, i, `should have ${i} DEMO poller(s)`); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + message: `DEMO poller "${traceNamePrefix}::${pollerName}::${pollerName.replace('System', 'Poller')}" exists already. Wait for results or delete it.` + }); + + assert.deepStrictEqual(service.numberOfDemoPollers, i, `should have ${i} DEMO poller(s)`); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + } + + for (let i = 1; i <= pollers.length; i += 1) { + const pollerName = pollers[i - 1]; + + let restOp = await sendRequest({ method: 'GET', path: `${ihURI}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 4); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOrActivePOllers.total); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + expectedNumberOrActivePOllers.total + 4); + assert.lengthOf(restOp.body.states, expectedNumberOrActivePOllers.total + 4); + + restOp = await sendRequest({ method: 'GET', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 4); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs + 4); + assert.lengthOf(restOp.body.states, expectedNAPNs + 4); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 4); + + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs + 4); + assert.lengthOf(restOp.body.states, expectedNAPNs + 4); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, + expectedNumberOrActivePOllers.total); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + expectedNumberOrActivePOllers.total + 4); + assert.lengthOf(restOp.body.states, expectedNumberOrActivePOllers.total + 4); + } + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 4); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs + 4); + assert.lengthOf(restOp.body.states, 4); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}?demo=true&all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 4); + assert.lengthOf(restOp.body.states, 4); + + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNAPNs); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNAPNs + 4); + } else { + assert.deepStrictEqual(restOp.body.numberOfPollers, + expectedNumberOrActivePOllers.total); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + expectedNumberOrActivePOllers.total + 4); + } + + const expectedNumberOfPollers = (pollerName.startsWith('disabled') ? 0 : 1); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNumberOfPollers + 1); + assert.lengthOf(restOp.body.states, expectedNumberOfPollers + 1); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNumberOfPollers + 1); + assert.lengthOf(restOp.body.states, 1); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}?demo=true&all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.lengthOf(restOp.body.states, 1); + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNumberOfPollers + 1); + } else { + const factor = ['enabledSystem', 'disabledSystem'].includes(pollerName) ? 2 : 1; + assert.deepStrictEqual(restOp.body.numberOfPollers, factor * expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + factor * expectedNumberOfPollers + 1); + } + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + if (namespaceConf.value) { + assert.deepStrictEqual(restOp.body.numberOfPollers, expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, expectedNumberOfPollers + 1); + assert.lengthOf(restOp.body.states, expectedNumberOfPollers + 1); + } else { + const factor = ['enabledSystem', 'disabledSystem'].includes(pollerName) ? 2 : 1; + assert.deepStrictEqual(restOp.body.numberOfPollers, factor * expectedNumberOfPollers); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, + factor * expectedNumberOfPollers + 1); + assert.lengthOf(restOp.body.states, factor * expectedNumberOfPollers + 1); + } + } + }); + + if (namespaceConf.value) { + it('should start DEMO poller with shared name', async () => { + const pollerName = 'enabledSystem'; + + let restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + + restOp = await sendRequest({ method: 'POST', path: `${ihURI}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(service.numberOfDemoPollers, 2, 'should have 2 DEMO poller(s)'); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active pollers'); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 2); + assert.lengthOf(restOp.body.states, 2); + + restOp = await sendRequest({ method: 'GET', path: `${ihURI}/${pollerName}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 2); + assert.lengthOf(restOp.body.states, 2); + + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/${pollerName}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollers, 1); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 2); + assert.lengthOf(restOp.body.states, 2); + + restOp = await sendRequest({ method: 'GET', path: `${ihURI}/${pollerName}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body.code, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.body.numberOfDemoPollers, 2); + assert.deepStrictEqual(restOp.body.numberOfPollers, 2); + assert.deepStrictEqual(restOp.body.numberOfPollersTotal, 4); + assert.lengthOf(restOp.body.states, 4); + }); + } + }); + + describe('DELETE /ihealthpoller', () => { + it('should remove orphaned DEMO pollers', async () => { + let restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total, 'should have active poller(s)'); + + delete (namespaceConf.value ? mainDeclaration.namespace : mainDeclaration).enabledSystem; + await processDeclaration(mainDeclaration); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + `"${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfPollers, expectedNumberOrActivePOllers.total - 1, 'should have active poller(s)'); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${uriPrefix}` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${ihURI}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + `DEMO_${traceNamePrefix}::enabledSystem::enabledPoller` + ], + numberOfDeletedDemoPollers: 1 + }); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have 0 DEMO poller(s)'); + }); + + it('should remove all DEMO pollers within namespace', async () => { + let restOp = await sendRequest({ method: 'DELETE', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + assert.deepStrictEqual(service.numberOfDemoPollers, 2, 'should have 2 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + `DEMO_${traceNamePrefix}::enabledSystem::enabledPoller`, + `DEMO_${traceNamePrefix}::disabledSystem::disabledPoller` + ], + numberOfDeletedDemoPollers: 2 + }); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::disabledSystem::disabledPoller" destroyed` + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have 0 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + }); + + if (namespaceConf.value) { + it('should remove all DEMO pollers', async () => { + let restOp = await sendRequest({ method: 'DELETE', path: `${ihURI}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${ihURI}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${ihURI}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + assert.deepStrictEqual(service.numberOfDemoPollers, 4, 'should have 4 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: ihURI }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + 'DEMO_f5telemetry_default::enabledSystem::enabledPoller', + 'DEMO_f5telemetry_default::disabledSystem::disabledPoller' + ], + numberOfDeletedDemoPollers: 2 + }); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + '"DEMO_f5telemetry_default::enabledSystem::enabledPoller" destroyed' + ); + assert.includeMatch( + coreStub.logger.messages.debug, + '"DEMO_f5telemetry_default::disabledSystem::disabledPoller" destroyed' + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 2, 'should have 2 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${ihURI}?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + `DEMO_${traceNamePrefix}::enabledSystem::enabledPoller`, + `DEMO_${traceNamePrefix}::disabledSystem::disabledPoller` + ], + numberOfDeletedDemoPollers: 2 + }); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::disabledSystem::disabledPoller" destroyed` + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have 0 DEMO poller(s)'); + }); + } + }); + + describe('DELETE /ihealthpoller/:system', () => { + it('should return 404 when unable to find object by name', async () => { + const restOp = await sendRequest({ method: 'DELETE', path: `${uriPrefix}/nonExistingPoller` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + error: `Bad URL: /mgmt/shared/telemetry${uriPrefix}/nonExistingPoller`, + message: 'Not Found' + }); + }); + + it('should remove DEMO poller by system name within namespace', async () => { + let restOp = await sendRequest({ method: 'DELETE', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(service.numberOfDemoPollers, 2, 'should have 2 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${uriPrefix}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + `DEMO_${traceNamePrefix}::disabledSystem::disabledPoller` + ], + numberOfDeletedDemoPollers: 1 + }); + + await testUtil.waitTill(() => { + assert.notIncludeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::disabledSystem::disabledPoller" destroyed` + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + }); + + if (namespaceConf.value) { + it('should remove DEMO poller by system name within namespace', async () => { + let restOp = await sendRequest({ method: 'DELETE', path: uriPrefix }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [], + numberOfDeletedDemoPollers: 0 + }); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${ihURI}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + restOp = await sendRequest({ method: 'POST', path: `${ihURI}/disabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + assert.deepStrictEqual(service.numberOfDemoPollers, 4, 'should have 4 DEMO poller(s)'); + + restOp = await sendRequest({ method: 'DELETE', path: `${ihURI}/disabledSystem?all=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.OK, + deletedDemoPollers: [ + `DEMO_${traceNamePrefix}::disabledSystem::disabledPoller`, + 'DEMO_f5telemetry_default::disabledSystem::disabledPoller' + ], + numberOfDeletedDemoPollers: 2 + }); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::disabledSystem::disabledPoller" destroyed` + ); + assert.includeMatch( + coreStub.logger.messages.debug, + '"DEMO_f5telemetry_default::disabledSystem::disabledPoller" destroyed' + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 2, 'should have 2 DEMO poller(s)'); + }); + } + }); + + it('should fail task when unable to decrypt config', async () => { + coreStub.logger.removeAllMessages(); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt error')); + + declaration = getTestSystem(); + createPollerMock(declaration); + + await testUtil.waitTill( + () => Object.values(coreStub.storage.restWorker.savedData.ihealth).length >= 1, + true + ); + + const storageKeys = Object.keys(coreStub.storage.restWorker.savedData.ihealth); + + const restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.error, + /DEMO.*iHealth Poller cycle failed due task error[\s\S]*expected decrypt error/gm + ); + return true; + }, true); + + assert.deepStrictEqual( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + storageKeys, + 'should not write DEMO data to the storage' + ); + }); + + it('should ignore config updates', async () => { + coreStub.logger.removeAllMessages(); + + const restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + declaration = getTestSystem(); + declaration.ihealthPoller.username = 'test_user_blabla'; + + await processDeclaration(mainDeclaration); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*::enabledSystem::enabledPoller.*Reason - configuration updated/ + ); + + assert.notIncludeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + coreStub.logger.removeAllMessages(); + await processDeclaration(mainDeclaration); + + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*::enabledSystem::enabledPoller.*Reason - configuration updated/ + ); + + assert.notIncludeMatch( + coreStub.logger.messages.debug, + `"DEMO_${traceNamePrefix}::enabledSystem::enabledPoller" destroyed` + ); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + }); + + it('should start and finish polling cycle once', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getTestSystem(); + createPollerMock(declaration); + + await testUtil.waitTill( + () => Object.values(coreStub.storage.restWorker.savedData.ihealth).length >= 1, + true + ); + + const storageKeys = Object.keys(coreStub.storage.restWorker.savedData.ihealth); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + let requestDone = false; + let restOp; + await Promise.all([ + (async () => { + restOp = await sendRequest({ method: 'POST', path: `${uriPrefix}/enabledSystem` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.CREATED); + requestDone = true; + })(), + forwardClock(getTimeStep(), () => requestDone) + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 1, 'should have 1 DEMO poller(s)'); + + requestDone = false; + await Promise.all([ + (async () => { + while (!requestDone) { + await testUtil.sleep(120 * 1000); + restOp = await sendRequest({ method: 'GET', path: `${uriPrefix}/enabledSystem?demo=true` }); + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + requestDone = restOp.body.states[0].terminated; + } + assert.deepStrictEqual(restOp.body.states[0].state.state.lastKnownState, 'DONE'); + })(), + forwardClock(getTimeStep(), () => requestDone) + ]); + + assert.deepStrictEqual( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + storageKeys, + 'should not write DEMO data to the storage' + ); + }); + })); + }); +}); diff --git a/test/unit/ihealth/serviceTests.js b/test/unit/ihealth/serviceTests.js new file mode 100644 index 00000000..dcc14f13 --- /dev/null +++ b/test/unit/ihealth/serviceTests.js @@ -0,0 +1,1165 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-constant-condition, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const os = require('os'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const helpers = require('./helpers'); +const PollerMock = require('./pollerMock'); +const reportDiag = require('./reportDiagnostics.json'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const assertLib = sourceCode('src/lib/utils/assert'); +const DataPipeline = sourceCode('src/lib/dataPipeline'); +const IHealthService = sourceCode('src/lib/ihealth'); +const IHealthPoller = sourceCode('src/lib/ihealth/poller'); + +moduleCache.remember(); + +describe('iHealth / iHealth Service', () => { + const downloadFolder = os.tmpdir(); + const localhost = 'localhost'; + const remotehost = 'remotehost.remotedonmain'; + let appEvents; + let configWorker; + let coreStub; + let dataPipeline; + let declaration; + let fakeClock; + let pollerDestroytub; + let pollerStartStub; + let pollerStopStub; + let reports; + let requestSpies; + let service; + + function createPollerMock(decl) { + return new PollerMock( + { + credentials: { + username: decl.ihealthPoller.username, + password: decl.ihealthPoller.passphrase.cipherText + }, + downloadFolder: decl.ihealthPoller.downloadFolder + }, + { + connection: { + host: decl.system.host, + port: decl.system.port, + protocol: decl.system.protocol + }, + credentials: { + username: decl.system.username, + password: decl.system.passphrase + ? decl.system.passphrase.cipherText + : undefined + } + } + ); + } + + async function forwardClock(time, cb) { + while (true) { + await fakeClock.clockForward(time, { repeat: 1, promisify: true, delay: 1 }); + try { + if (await cb()) { + break; + } + } catch (error) { + // igonre + } + } + } + + function getTimeStep() { + let step = 1; + if (declaration) { + if (declaration.ihealthPoller.interval.frequency === 'monthly') { + step = 48; + } + if (declaration.ihealthPoller.interval.frequency === 'weekly') { + step = 24; + } + } + return step * 60 * 60 * 1000; + } + + function processDeclaration(decl, namespace, wait = true, addConsumer = true) { + if (addConsumer) { + decl = Object.assign({}, decl, { + consumer: dummies.declaration.consumer.default.decrypted({}) + }); + } + + let promise; + if (namespace) { + promise = configWorker.processNamespaceDeclaration( + dummies.declaration.namespace.base.decrypted(decl), + namespace + ); + } else { + promise = configWorker.processDeclaration( + dummies.declaration.base.decrypted(decl) + ); + } + return Promise.all([ + wait ? appEvents.waitFor('ihealth.config.applied') : Promise.resolve(), + promise + ]); + } + + function verifyReport(report) { + assert.deepStrictEqual(report.data.diagnostics, reportDiag, 'should match expected diagnostics'); + assertLib.string(report.data.system.hostname, 'hostname'); + assertLib.string(report.data.system.ihealthLink, 'ihealthLink'); + assertLib.string(report.data.system.qkviewNumber, 'qkviewNumber'); + assertLib.object(report.data.telemetryServiceInfo, 'telemetryServiceInfo'); + assertLib.string(report.data.telemetryServiceInfo.cycleStart, 'telemetryServiceInfo.cycleStart'); + assertLib.string(report.data.telemetryServiceInfo.cycleEnd, 'telemetryServiceInfo.cycleEnd'); + assert.deepStrictEqual(report.data.telemetryEventCategory, 'ihealthInfo'); + assert.deepStrictEqual(report.type, 'ihealthInfo'); + } + + function verifyReports() { + reports.map(verifyReport); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + declaration = null; + fakeClock = null; + requestSpies = testUtil.requestSpies(); + + pollerDestroytub = sinon.stub(IHealthPoller.prototype, 'destroy'); + pollerDestroytub.callThrough(); + pollerStartStub = sinon.stub(IHealthPoller.prototype, 'start'); + pollerStartStub.callThrough(); + pollerStopStub = sinon.stub(IHealthPoller.prototype, 'stop'); + pollerStopStub.callThrough(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + dataPipeline = new DataPipeline(); + dataPipeline.initialize(appEvents); + + service = new IHealthService(); + service.initialize(appEvents); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPollers, 0); + + await dataPipeline.start(); + await service.start(); + await coreStub.startServices(); + + await Promise.all([ + appEvents.waitFor('ihealth.config.applied'), + coreStub.configWorker.configWorker.load() + ]); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPollers, 0); + assert.isEmpty(coreStub.logger.messages.error); + + coreStub.logger.removeAllMessages(); + + reports = []; + service.ee.on('report', (report) => reports.push(report)); + + await coreStub.utilMisc.fs.promise.mkdir(downloadFolder); + }); + + afterEach(async () => { + if (fakeClock) { + fakeClock.stub.restore(); + } + await service.destroy(); + await dataPipeline.destroy(); + await coreStub.destroyServices(); + + assert.isTrue(service.isDestroyed()); + + testUtil.nockCleanup(); + sinon.restore(); + + verifyReports(); + + if (declaration) { + helpers.checkBigIpRequests(declaration, requestSpies); + helpers.checkIHealthRequests(declaration, requestSpies); + } + }); + + it('should remove obsolete data from the storage if exists (no config)', async () => { + coreStub.storage.restWorker.loadData = { + ihealth: { + obsoleteKey: 'something', + obsoleteKey2: 'something' + } + }; + await Promise.all([ + appEvents.waitFor('ihealth.config.applied'), + coreStub.restartServices() + ]); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.deepStrictEqual(coreStub.storage.restWorker.savedData.ihealth, {}, 'should remove obsolete keys'); + assert.includeMatch( + coreStub.logger.messages.debug, + /Removing obsolete data from the iHealth storage/ + ); + }); + + it('should unsubscribe from config updates once destroyed', async () => { + await processDeclaration(helpers.attachPoller( + helpers.ihealthPoller({ downloadFolder }), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.isEmpty(coreStub.logger.messages.error); + assert.includeMatch( + coreStub.logger.messages.all, + /IHealthService.*Config "change" event/ + ); + + await service.destroy(); + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have no active poller'); + + coreStub.logger.removeAllMessages(); + await processDeclaration({}, undefined, false); + + assert.notIncludeMatch( + coreStub.logger.messages.all, + /IHealthService.*Config "change" event/ + ); + + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have no active poller'); + }); + + it('should wait till config update complete before proceed with service destroying', async () => { + const destroyPromise = new Promise((resolve) => { + pollerStartStub.callsFake(() => { + service.destroy().then(resolve); + }); + }); + + await processDeclaration(helpers.attachPoller( + helpers.ihealthPoller({ downloadFolder }), + helpers.system() + )); + + await destroyPromise; + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have no active poller'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Waiting for config routine to finish/ + ); + }); + + it('should log error when unable to start poller', async () => { + pollerStartStub.rejects(new Error('expected poller start error')); + await processDeclaration(helpers.attachPoller( + helpers.ihealthPoller({ downloadFolder }), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have 0 active pollers'); + assert.includeMatch( + coreStub.logger.messages.error, + /IHealthService.*Uncaught error on attempt to start.*ihealthPoller[\s\S]*expected poller start error/gm + ); + }); + + it('should log error when unable to destroy poller', async () => { + let poller; + pollerDestroytub.callsFake(function () { + poller = this; + throw new Error('expected poller destroy error'); + }); + await processDeclaration(helpers.attachPoller( + helpers.ihealthPoller({ downloadFolder }), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + + await processDeclaration({}); + assert.includeMatch( + coreStub.logger.messages.error, + /IHealthService.*Uncaught error on attempt to destroy poller.*ihealthPoller[\s\S]*expected poller destroy error/gm + ); + pollerDestroytub.restore(); + await poller.destroy(); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have 0 active pollers'); + }); + + it('should log error when unable to destroy all pollers', async () => { + const pollers = []; + pollerDestroytub.callsFake(function () { + pollers.push(this); + throw new Error('expected poller destroy error'); + }); + + const decl = helpers.attachPoller( + helpers.ihealthPoller({ downloadFolder }), + helpers.system() + ); + decl.system2 = testUtil.deepCopy(decl.system); + + await processDeclaration(decl); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 2, 'should have 2 active pollers'); + + await service.destroy(); + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have 0 active pollers'); + + assert.includeMatch( + coreStub.logger.messages.error, + /IHealthService.*Uncaught error on attempt to destroy poller.*ihealthPoller[\s\S]*expected poller destroy error/gm + ); + pollerDestroytub.restore(); + await Promise.all(pollers.map((p) => p.destroy())); + }); + + describe('configuration variations', () => { + const combinations = testUtil.product( + // interval + testUtil.smokeTests.filter([ + { + name: 'daily', + value: { frequency: 'daily' } + }, + { + name: 'weekly (day number)', + value: { frequency: 'weekly', dayNo: true } + }, + { + name: 'weekly (day name)', + value: { frequency: 'weekly', dayStr: true } + }, + { + name: 'monthly', + value: { frequency: 'monthly' } + } + ]), + // proxy + testUtil.smokeTests.filter([ + { + name: 'no proxy', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'minimal and no auth', + value: { full: false } + }), + testUtil.smokeTests.ignore({ + name: 'minimal and user only', + value: { full: false, userOnly: true } + }), + testUtil.smokeTests.ignore({ + name: 'minimal and user and pass', + value: { full: false, auth: true } + }), + testUtil.smokeTests.ignore({ + name: 'all props no auth', + value: { full: true } + }), + testUtil.smokeTests.ignore({ + name: 'all props and user only', + value: { full: true, userOnly: true } + }), + { + name: 'all props and user and pass', + value: { full: true, auth: true } + } + ]), + // system auth + testUtil.smokeTests.filter([ + { + name: 'system without user', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'system with user', + value: { username: true } + }), + { + name: 'system with user and passphrase', + value: { username: true, passphrase: true } + } + ]), + // system connection + testUtil.smokeTests.filter([ + { + name: 'localhost system', + value: undefined + }, + testUtil.smokeTests.ignore({ + name: 'localhost system (explicit)', + value: { + host: localhost + } + }), + testUtil.smokeTests.ignore({ + name: 'localhost system with non default config', + value: { + allowSelfSignedCert: true, + port: 8888, + protocol: 'https' + } + }), + testUtil.smokeTests.ignore({ + name: 'remote system', + value: { + host: remotehost + } + }), + { + name: 'remote system with non default config', + value: { + host: remotehost, + allowSelfSignedCert: true, + port: 8889, + protocol: 'https' + } + } + ]), + // namespace + [ + { + name: 'default', + value: undefined + }, + { + name: 'custom', + value: 'namespace' + } + ] + ); + + combinations.forEach(([intervalConf, proxyConf, systemAuthConf, systemConf, namespaceConf]) => describe(`interval = ${intervalConf.name}, proxy = ${proxyConf.name}, system = ${systemConf.name}, systemAuth = ${systemAuthConf.name}, namespace = ${namespaceConf.name}`, + () => { + if (systemConf.value && systemConf.value.host === remotehost + && !(systemAuthConf.value && systemAuthConf.value.passphrase) + ) { + return; + } + + function getDeclaration(enable = true, trace = false) { + return helpers.getDeclaration({ + downloadFolder, + enable, + intervalConf, + proxyConf, + systemAuthConf, + systemConf, + trace + }); + } + + testUtil.product( + // enable + [ + { + name: 'enabled', + value: true + }, + { + name: 'disabled', + value: false + } + ], + // trace + [ + { + name: 'enabled', + value: true + }, + { + name: 'disabled', + value: false + } + ] + ).forEach(([enableConf, traceConf]) => it(`enable = ${enableConf.name}, trace = ${traceConf.name}`, async () => { + await processDeclaration(getDeclaration(enableConf.value, traceConf.value), namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, enableConf.value ? 1 : 0, 'should have expected number of active pollers'); + assert.isEmpty(coreStub.logger.messages.error); + })); + + it('should process poller config', async () => { + await processDeclaration(getDeclaration(), namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.isEmpty(coreStub.logger.messages.error); + }); + + it('should not restart existing poller if no config changed', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.consumer = dummies.declaration.consumer.default.decrypted(); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const configID = configWorker.currentConfig.components.find((c) => c.iHealth).id; + const dstIds = configWorker.currentConfig.mappings[configID]; + + assert.isNotEmpty(configID, 'should find ID'); + assert.isNotEmpty(dstIds, 'should have receivers'); + + await forwardClock(getTimeStep(), () => reports.length > 2); + + reports.forEach((report) => { + assert.deepStrictEqual(report.sourceId, configID); + assert.deepStrictEqual(report.destinationIds, dstIds); + }); + + coreStub.logger.removeAllMessages(); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*ihealthPoller/ + ); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const newConfigID = configWorker.currentConfig.components.find((c) => c.iHealth).id; + const newDstIds = configWorker.currentConfig.mappings[newConfigID]; + + assert.isNotEmpty(newConfigID, 'should find ID'); + assert.isNotEmpty(newDstIds, 'should have receivers'); + assert.notDeepEqual(newConfigID, configID, 'should generated new ID'); + assert.notDeepEqual(dstIds, newDstIds, 'should update receivers'); + + const reportIdx = reports.length; + await forwardClock(getTimeStep(), () => reports.length > (reportIdx + 3)); + + reports.forEach((report, idx) => { + if (idx >= reportIdx) { + assert.deepStrictEqual(report.sourceId, newConfigID, 'should use new IDs'); + assert.deepStrictEqual(report.destinationIds, newDstIds, 'should use new IDs'); + } + }); + }); + + it('should restart existing poller when config changed', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.consumer = dummies.declaration.consumer.default.decrypted(); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const configID = configWorker.currentConfig.components.find((c) => c.iHealth).id; + const dstIds = configWorker.currentConfig.mappings[configID]; + + assert.isNotEmpty(configID, 'should find ID'); + assert.isNotEmpty(dstIds, 'should have receivers'); + + await forwardClock(getTimeStep(), () => reports.length > 2); + + reports.forEach((report) => { + assert.deepStrictEqual(report.sourceId, configID); + assert.deepStrictEqual(report.destinationIds, dstIds); + }); + + // at this step we should have some data in the storage + const hashes = Object.keys(coreStub.storage.restWorker.savedData.ihealth); + + if (declaration.ihealthPoller.interval.frequency === 'daily') { + declaration.ihealthPoller.interval.frequency = 'weekly'; + declaration.ihealthPoller.interval.day = 0; + } else { + declaration.ihealthPoller.interval.frequency = 'daily'; + delete declaration.ihealthPoller.interval.day; + } + + coreStub.logger.removeAllMessages(); + await processDeclaration(declaration, namespaceConf.value); + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Poller.*system::ihealthPoller.*destroyed/ + ); + return true; + }); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*ihealthPoller.*Reason - configuration updated/ + ); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*ihealthPoller/ + ); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 1); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const newConfigID = configWorker.currentConfig.components.find((c) => c.iHealth).id; + const newDstIds = configWorker.currentConfig.mappings[newConfigID]; + + assert.isNotEmpty(newConfigID, 'should find ID'); + assert.isNotEmpty(newDstIds, 'should have receivers'); + assert.notDeepEqual(newConfigID, configID, 'should generated new ID'); + assert.notDeepEqual(dstIds, newDstIds, 'should update receivers'); + + const reportIdx = reports.length; + await forwardClock(getTimeStep(), () => reports.length > (reportIdx + 3)); + + reports.forEach((report, idx) => { + if (idx >= reportIdx) { + assert.deepStrictEqual(report.sourceId, newConfigID, 'should use new IDs'); + assert.deepStrictEqual(report.destinationIds, newDstIds, 'should use new IDs'); + } + }); + + assert.isFalse( + Object.keys(coreStub.storage.restWorker.savedData.ihealth).some((h) => hashes.includes(h)), + 'should use different hash as the storage key' + ); + }); + + it('should destroy poller when removed from the declaration', async () => { + coreStub.logger.removeAllMessages(); + const decl = getDeclaration(); + + let declCopy = testUtil.deepCopy(decl); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const firstPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth)[0]; + assert.isString(firstPolerHash); + + declCopy = testUtil.deepCopy(decl); + declCopy.system2 = testUtil.deepCopy(decl.system); + + coreStub.logger.removeAllMessages(); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 2, 'should have 2 active pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*system::ihealthPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system2::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const secondPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth) + .find((h) => firstPolerHash !== h); + assert.isString(secondPolerHash); + + delete declCopy.system; + + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*system2::ihealthPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.include( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + secondPolerHash + ); + assert.notInclude( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + firstPolerHash + ); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 1); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + delete declCopy.system2; + + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Poller.*system2::ihealthPoller.*destroyed/ + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have 0 active pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 2); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.isEmpty(coreStub.storage.restWorker.savedData.ihealth); + }); + + it('should destroy poller when no consumers defined', async () => { + coreStub.logger.removeAllMessages(); + const decl = getDeclaration(); + + let declCopy = testUtil.deepCopy(decl); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const firstPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth)[0]; + assert.isString(firstPolerHash); + + declCopy = testUtil.deepCopy(decl); + declCopy.system2 = testUtil.deepCopy(decl.system); + + coreStub.logger.removeAllMessages(); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 2, 'should have 2 active pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*system::ihealthPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system2::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const secondPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth) + .find((h) => firstPolerHash !== h); + assert.isString(secondPolerHash); + + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value, true, false); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*system2::ihealthPoller.*Reason - configuration updated/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.notInclude( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + secondPolerHash + ); + assert.notInclude( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + firstPolerHash + ); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 0, 'should have 0 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 2); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(coreStub.storage.restWorker.savedData.ihealth); + }); + + it('should not restart existing poller if no config changed (new namespace created)', async () => { + const decl = getDeclaration(); + await processDeclaration(decl, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const firstPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth)[0]; + + coreStub.logger.removeAllMessages(); + await processDeclaration(decl, 'namesapce-new'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 2, 'should have 2 active pollers'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*ihealthPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*namesapce-new::system::ihealthPoller.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + // at this step we should have some data in the storage + const secondPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth) + .find((h) => firstPolerHash !== h); + assert.isString(secondPolerHash); + + coreStub.logger.removeAllMessages(); + await processDeclaration({}, 'namesapce-new'); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*namesapce-new::system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*system::ihealthPoller/ + ); + + assert.include( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + firstPolerHash + ); + assert.notInclude( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + secondPolerHash + ); + }); + + if (namespaceConf.value) { + it('should not restart existing poller if no config changed (root namespace updated)', async () => { + const decl = getDeclaration(); + await processDeclaration(decl, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const firstPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth)[0]; + + decl[namespaceConf.value] = dummies.declaration.namespace.base.decrypted(Object.assign( + {}, + testUtil.deepCopy(decl), + { consumer: dummies.declaration.consumer.default.decrypted({}) } + )); + + coreStub.logger.removeAllMessages(); + await processDeclaration(decl); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 2, 'should have 2 active pollers'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*namespace::system::ihealthPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*f5telemetry_default::system::ihealthPoller.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + // at this step we should have some data in the storage + const secondPolerHash = Object.keys(coreStub.storage.restWorker.savedData.ihealth) + .find((h) => firstPolerHash !== h); + assert.isString(secondPolerHash); + + delete decl.system; + + coreStub.logger.removeAllMessages(); + await processDeclaration(decl); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Removed obsolete data from the iHealth storage/ + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*Removing iHealth Poller.*f5telemetry_default::system::ihealthPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /IHealthService.*No configuration changes for.*namespace::system::ihealthPoller/ + ); + + assert.include( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + firstPolerHash + ); + assert.notInclude( + Object.keys(coreStub.storage.restWorker.savedData.ihealth), + secondPolerHash + ); + }); + } + + it('should schedule execution date correctly', async () => { + await processDeclaration(getDeclaration(), namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.verbose, + /IHealthService.Poller.*system::.*Poller.*Going to sleep for/ + ); + return true; + }, true); + + // at this step we should have some data in the storage + const hash = Object.keys(coreStub.storage.restWorker.savedData.ihealth)[0]; + const execDate = coreStub.storage.restWorker.savedData.ihealth[hash].state.execDate; + + const tw = helpers.getNextExecWindow(intervalConf.value.frequency); + assert.isAtLeast(execDate, tw[0].getTime() - 600 * 1000, `exec date ${new Date(execDate)} should be >= ${tw[0]}`); + assert.isAtMost(execDate, tw[1].getTime() + 600 * 1000, `exec date ${new Date(execDate)} should be <= ${tw[1]}`); + }); + + it('should start and finish polling cycle multipe times', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(reports); + + const decryptCC = coreStub.deviceUtil.decrypt.callCount; + + await forwardClock(getTimeStep(), () => reports.length > 2); + + assert.isEmpty(coreStub.logger.messages.error); + const storage = Object.values(coreStub.storage.restWorker.savedData.ihealth); + + assert.lengthOf(storage, 1); + + const pollerState = storage[0]; + assert.deepStrictEqual(pollerState.version, '3.0'); + assert.isAbove(pollerState.history.length, 2); + + assert.isAtLeast(pollerState.stats.cycles, pollerState.history.length + 1); + assert.isAtLeast(pollerState.stats.cyclesCompleted, pollerState.history.length); + assert.isAtLeast(pollerState.stats.qkviewsCollected, pollerState.history.length); + assert.isAtLeast(pollerState.stats.qkviewsUploaded, pollerState.history.length); + assert.isAtLeast(pollerState.stats.reportsCollected, pollerState.history.length); + assert.isAtLeast(pollerState.stats.qkviewCollectRetries, 0); + assert.isAtLeast(pollerState.stats.qkviewUploadRetries, 0); + assert.isAtLeast(pollerState.stats.reportCollectRetries, 0); + + // also it should decrypt config multiple times + assert.isAbove( + coreStub.deviceUtil.decrypt.callCount, + decryptCC, + 'should decrypt config multiple times' + ); + }); + + it('should fail task when unable to decrypt config', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPollers, 1, 'should have 1 active poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(reports); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt error')); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.error, + /iHealth Poller cycle failed due task error[\s\S]*expected decrypt error/gm + ); + return true; + }); + }); + })); + }); +}); diff --git a/test/unit/ihealthPollerTests.js b/test/unit/ihealthPollerTests.js deleted file mode 100644 index c645b84c..00000000 --- a/test/unit/ihealthPollerTests.js +++ /dev/null @@ -1,1104 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const getByKey = require('lodash/get'); -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const dummies = require('./shared/dummies'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); -const testUtil = require('./shared/util'); - -const configWorker = sourceCode('src/lib/config'); -const IHealthPoller = sourceCode('src/lib/ihealthPoller'); -const ihealthUtil = sourceCode('src/lib/utils/ihealth'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); - -moduleCache.remember(); - -describe('IHealthPoller', () => { - let coreStub; - let ihealthStub; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - coreStub = stubs.default.coreStub(); - ihealthStub = stubs.iHealthPoller({ - ihealthUtil - }); - - return Promise.all(IHealthPoller.getAll({ includeDemo: true }) - .map((poller) => IHealthPoller.disable(poller, true))) - .then((retObjs) => Promise.all(retObjs.map((obj) => obj.stopPromise))) - .then(() => { - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have no running iHealth Pollers before any test'); - return persistentStorage.persistentStorage.load(); - }); - }); - - afterEach(() => Promise.all(IHealthPoller.getAll({ includeDemo: true }) - .map((poller) => IHealthPoller.disable(poller, true))) - .then((retObjs) => Promise.all(retObjs.map((obj) => obj.stopPromise))) - .then(() => { - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have no running iHealth Pollers'); - sinon.restore(); - })); - - describe('class methods', () => { - describe('.cleanupOrphanedStorageData()', () => { - it('should remove orphaned data', () => { - const poller1 = IHealthPoller.create('id1'); - const poller2 = IHealthPoller.create('id2'); - return persistentStorage.persistentStorage.set('ihealth', { id1: true, id2: true }) - .then(() => persistentStorage.persistentStorage.get('ihealth')) - .then((ihealthStorageData) => { - assert.includeMembers( - Object.keys(ihealthStorageData), - ['id1', 'id2'], - 'should save data for both pollers' - ); - IHealthPoller.unregister(poller2); - return IHealthPoller.cleanupOrphanedStorageData(); - }) - .then(() => persistentStorage.persistentStorage.get('ihealth')) - .then((ihealthStorageData) => { - assert.deepStrictEqual( - Object.keys(ihealthStorageData), - ['id1'], - 'should remove data for poller with id2' - ); - IHealthPoller.unregister(poller1); - }); - }); - }); - - describe('.create()', () => { - it('should create and register instance', () => { - const poller = IHealthPoller.create('poller1'); - assert.instanceOf(poller, IHealthPoller, 'should be instance of IHealthPoller'); - assert.deepStrictEqual( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - ['poller1'], - 'should register iHealth Poller' - ); - assert.isEmpty(IHealthPoller.getAll({ demoOnly: true }).map((p) => p.id), 'should have no demo instances'); - }); - - it('should create and register instance (demo instance)', () => { - const poller = IHealthPoller.create('demoPoller1', { demo: true }); - assert.instanceOf(poller, IHealthPoller, 'should be instance of IHealthPoller'); - assert.deepStrictEqual( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - ['demoPoller1'], - 'should register demo iHealth Poller' - ); - assert.isEmpty(IHealthPoller.getAll({ includeDemo: false }).map((p) => p.id), 'should have no non-demo instances'); - }); - - it('should throw error when instance exists already', () => { - IHealthPoller.create('poller1'); - assert.throws( - () => IHealthPoller.create('poller1'), - /created already/, - 'should throw error on attempt to create instance with same ID' - ); - }); - - it('should throw error when instance exists already (demo instance)', () => { - IHealthPoller.create('poller1', { demo: true }); - assert.throws( - () => IHealthPoller.create('poller1', { demo: true }), - /created already/, - 'should throw error on attempt to create demo instance with same ID' - ); - }); - - it('should not throw error when creating demo instance with same ID', () => { - IHealthPoller.create('poller1'); - assert.doesNotThrow( - () => IHealthPoller.create('poller1', { demo: true }), - 'should not throw error on attempt to create demo instance with existing ID' - ); - }); - }); - - describe('.createDemo()', () => { - it('should create and register instance', () => { - const poller = IHealthPoller.createDemo('poller1'); - assert.instanceOf(poller, IHealthPoller, 'should be instance of IHealthPoller'); - assert.deepStrictEqual( - IHealthPoller.getAll({ includeDemo: true }).map((p) => p.id), - ['poller1'], - 'should register iHealth Poller' - ); - assert.deepStrictEqual( - IHealthPoller.getAll({ demoOnly: true }).map((p) => p.id), - ['poller1'], - 'should register as demo instance' - ); - }); - - it('should throw error when instance exists already', () => { - IHealthPoller.createDemo('poller1'); - assert.throws( - () => IHealthPoller.createDemo('poller1'), - /created already/, - 'should throw error on attempt to create instance with same ID' - ); - }); - - it('should not throw error when creating demo instance with same ID', () => { - IHealthPoller.createDemo('poller1'); - assert.doesNotThrow( - () => IHealthPoller.create('poller1'), - 'should not throw error on attempt to create demo instance with existing ID' - ); - }); - }); - - describe('.disable()', () => { - it('should disable not started instance', () => { - const poller = IHealthPoller.create('id'); - return IHealthPoller.disable(poller) - .then((retObj) => { - assert.instanceOf(retObj.stopPromise, Promise, 'should be instance of Promise'); - return retObj.stopPromise; - }); - }); - - it('should disable started instance', () => { - const poller = IHealthPoller.create('id'); - return poller.start() - .then(() => IHealthPoller.disable(poller)) - .then((retObj) => { - assert.isEmpty(IHealthPoller.get('id'), 'should unregister instance once disabled it'); - assert.instanceOf(retObj.stopPromise, Promise, 'should be instance of Promise'); - return retObj.stopPromise; - }); - }); - - it('should not fail on attempt to disable instance when disabling process started already', () => { - const poller = IHealthPoller.create('id'); - return poller.start() - .then(() => Promise.all([IHealthPoller.disable(poller), IHealthPoller.disable(poller)])) - .then((retObj) => { - assert.isEmpty(IHealthPoller.get('id'), 'should unregister instance once disabled it'); - assert.instanceOf(retObj[0].stopPromise, Promise, 'should be instance of Promise'); - assert.instanceOf(retObj[1].stopPromise, Promise, 'should be instance of Promise'); - return Promise.all([retObj[0].stopPromise, retObj[1].stopPromise]); - }); - }); - }); - - describe('.get()', () => { - it('should return null when instance with such ID doesn\'t exist', () => { - assert.isEmpty(IHealthPoller.get('id'), 'should return no instances with such ID'); - }); - - it('should return instance by ID', () => { - const poller = IHealthPoller.create('id'); - assert.instanceOf(poller, IHealthPoller, 'should be instance of IHealthPoller'); - assert.lengthOf(IHealthPoller.get('id'), 1, 'should have only one instance'); - assert.isTrue(poller === IHealthPoller.get('id')[0], 'should be the same instance'); - }); - - it('should return null when demo instance with such ID registered too', () => { - IHealthPoller.createDemo('id'); - assert.deepStrictEqual( - IHealthPoller.get('id').find((p) => !p.isDemoModeEnabled()), - undefined, - 'should have no instance with such ID' - ); - }); - - it('should return instance by ID', () => { - const poller = IHealthPoller.createDemo('id'); - assert.instanceOf(poller, IHealthPoller, 'should be instance of IHealthPoller'); - assert.isTrue(poller === IHealthPoller.get('id')[0], 'should be the same instance'); - }); - - it('should return null when non-demo instance with such ID registered too', () => { - IHealthPoller.create('id'); - assert.deepStrictEqual( - IHealthPoller.get('id').find((p) => p.isDemoModeEnabled()), - undefined, - 'should have no instance with such ID' - ); - }); - }); - - describe('.getAll()', () => { - it('should return empty array when no instances registered', () => { - assert.isEmpty(IHealthPoller.getAll(), 'should have no instances by default'); - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have no instances at all by default'); - assert.isEmpty(IHealthPoller.getAll({ demoOnly: true }), 'should have no demo instances by default'); - assert.isEmpty(IHealthPoller.getAll({ demoOnly: true, includeDemo: true }), 'should have no demo instances by default'); - }); - - it('should return non-demo instances only', () => { - const poller1 = IHealthPoller.create('id1'); - const poller2 = IHealthPoller.create('id2'); - IHealthPoller.create('id2', { demo: true }); - assert.sameDeepMembers(IHealthPoller.getAll(), [poller1, poller2], 'should return registered instances'); - }); - - it('should return demo instances only', () => { - const poller1 = IHealthPoller.create('id1', { demo: true }); - const poller2 = IHealthPoller.create('id2', { demo: true }); - IHealthPoller.create('id2'); - assert.sameDeepMembers( - IHealthPoller.getAll({ demoOnly: true }), - [poller1, poller2], - 'should return registered demo instances' - ); - assert.sameDeepMembers( - IHealthPoller.getAll({ demoOnly: true, includeDemo: true }), - [poller1, poller2], - 'should return registered demo instances' - ); - }); - - it('should include demo instances too', () => { - const demoPoller1 = IHealthPoller.create('id1', { demo: true }); - const demoPoller2 = IHealthPoller.create('id2', { demo: true }); - const poller1 = IHealthPoller.create('id1'); - const poller2 = IHealthPoller.create('id2'); - assert.sameDeepMembers( - IHealthPoller.getAll({ includeDemo: true }), - [poller1, poller2, demoPoller1, demoPoller2], - 'should return all registered instances' - ); - }); - }); - - describe('.unregister()', () => { - const getOrCreate = (id, opts) => IHealthPoller.get(id).find((p) => !p.isDemoModeEnabled()) - || IHealthPoller.create(id, opts); - const getOrCreateDemo = (id, opts) => IHealthPoller.get(id).find((p) => p.isDemoModeEnabled()) - || IHealthPoller.createDemo(id, opts); - - it('should unregister instance', () => { - getOrCreate('id1'); - getOrCreateDemo('id1'); - assert.sameDeepMembers( - IHealthPoller.getAll({ includeDemo: true }), - [getOrCreate('id1'), getOrCreateDemo('id1')], - 'should have registered instance' - ); - IHealthPoller.unregister(getOrCreate('id1')); - assert.sameDeepMembers( - IHealthPoller.getAll({ includeDemo: true }), - [getOrCreateDemo('id1')], - 'should have registered instance' - ); - }); - - it('should unregister demo instance', () => { - getOrCreate('id1'); - getOrCreateDemo('id1'); - assert.sameDeepMembers( - IHealthPoller.getAll({ includeDemo: true }), - [getOrCreate('id1'), getOrCreateDemo('id1')], - 'should have registered instance' - ); - IHealthPoller.unregister(getOrCreateDemo('id1')); - assert.deepStrictEqual( - IHealthPoller.getAll({ includeDemo: true }), - [getOrCreate('id1')], - 'should have no registered instances' - ); - }); - - it('should not fail on attempt to unregister not registered instance', () => { - const poller = getOrCreate('id1'); - const demoPoller = getOrCreateDemo('id1'); - - IHealthPoller.unregister(poller); - IHealthPoller.unregister(demoPoller); - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have no registered instances'); - assert.doesNotThrow( - () => IHealthPoller.unregister(poller), - 'should not throw error when instance not registered' - ); - assert.doesNotThrow( - () => IHealthPoller.unregister(demoPoller), - 'should not throw error when demo instance not registered' - ); - }); - }); - }); - - describe('instance', () => { - const qkviewFile = 'qkviewFile'; - const qkviewURI = 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/0000000'; - let declaration; - let instance; - let instanceID; - let instanceName; - - beforeEach(() => { - declaration = dummies.declaration.base.decrypted(); - declaration.System = dummies.declaration.system.full.decrypted({ - username: 'test_user_1', - passphrase: { cipherText: 'test_pass_1' } - }); - declaration.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_2', - passphrase: { cipherText: 'test_pass_2' }, - proxy: { - username: 'test_user_3', - passphrase: { cipherText: 'test_pass_3' } - } - }); - instanceID = 'f5telemetry_default::System::iHealthPoller_1'; // traceName - instanceName = 'iHealthPoller_1'; // auto-generated name - instance = IHealthPoller.create(instanceID, { name: instanceName }); - coreStub.utilMisc.generateUuid.numbersOnly = false; - return configWorker.processDeclaration(testUtil.deepCopy(declaration)); - }); - - describe('constructor', () => { - it('should configure instance without options', () => { - instance = IHealthPoller.create('instanceID_2'); - assert.deepStrictEqual(instance.disablingPromise, undefined, 'should have no disablingPromise right after instantiation'); - assert.deepStrictEqual(instance.id, 'instanceID_2', 'should match expected ID'); - assert.deepStrictEqual(instance.name, 'IHealthPoller_instanceID_2', 'should match expected name'); - assert.deepStrictEqual(instance.storageKey, 'instanceID_2', 'should match expected storageKey'); - assert.deepStrictEqual(instance.isActive(), false, 'should not be active right after instantiation'); - assert.deepStrictEqual(instance.isDisabled(), false, 'should not be disabled right after instantiation'); - assert.deepStrictEqual(instance.isDemoModeEnabled(), false, 'should not be in "demo" mode'); - }); - - it('should configure instance with options', () => { - instance = IHealthPoller.create('instanceID_2', { demo: false, name: 'instanceName' }); - assert.deepStrictEqual(instance.disablingPromise, undefined, 'should have no disablingPromise right after instantiation'); - assert.deepStrictEqual(instance.id, 'instanceID_2', 'should match expected ID'); - assert.deepStrictEqual(instance.name, 'instanceName', 'should match expected name'); - assert.deepStrictEqual(instance.storageKey, 'instanceID_2', 'should match expected storageKey'); - assert.deepStrictEqual(instance.isActive(), false, 'should not be active right after instantiation'); - assert.deepStrictEqual(instance.isDisabled(), false, 'should not be disabled right after instantiation'); - assert.deepStrictEqual(instance.isDemoModeEnabled(), false, 'should not be in "demo" mode'); - }); - - it('should configure instance with options (demo mode)', () => { - instance = IHealthPoller.create('instanceID_2', { demo: true, name: 'instanceName' }); - assert.deepStrictEqual(instance.disablingPromise, undefined, 'should have no disablingPromise right after instantiation'); - assert.deepStrictEqual(instance.id, 'instanceID_2', 'should match expected ID'); - assert.deepStrictEqual(instance.name, 'instanceName (DEMO)', 'should match expected name'); - assert.deepStrictEqual(instance.storageKey, 'instanceID_2', 'should match expected storageKey'); - assert.deepStrictEqual(instance.isActive(), false, 'should not be active right after instantiation'); - assert.deepStrictEqual(instance.isDisabled(), false, 'should not be disabled right after instantiation'); - assert.deepStrictEqual(instance.isDemoModeEnabled(), true, 'should be in "demo" mode'); - }); - }); - - describe('.cleanup()', () => { - it('should perform instance cleanup', () => { - instance.on('died', () => {}); - assert.deepStrictEqual(instance.hasListeners(), true, 'should have registered listeners'); - - instance.cleanup(); - assert.deepStrictEqual(instance.hasListeners(), false, 'should have not registered listeners'); - assert.doesNotThrow( - () => instance.cleanup(), - 'should not fail when tried to cleanup more than once' - ); - IHealthPoller.unregister(instance); - }); - }); - - describe('.getConfig()', () => { - it('should reject when no configuration found', () => { - instance = IHealthPoller.create('instanceID_2'); - return assert.isRejected( - instance.getConfig(), - /Configuration for iHealth Poller "IHealthPoller_instanceID_2" \(instanceID_2\) not found/ - ); - }); - - it('should be able to find configuration by ID', () => assert.becomes( - instance.getConfig(), - dummies.configuration.ihealthPoller.full.encrypted({ - name: 'iHealthPoller_1', - id: 'f5telemetry_default::System::iHealthPoller_1', - trace: true, - traceName: 'f5telemetry_default::System::iHealthPoller_1', - iHealth: { - name: 'iHealthPoller_1', - credentials: { - username: 'test_user_2', - passphrase: { - cipherText: '$M$test_pass_2' - } - }, - proxy: { - credentials: { - username: 'test_user_3', - passphrase: { - cipherText: '$M$test_pass_3' - } - } - } - }, - system: { - credentials: { - username: 'test_user_1', - passphrase: { - cipherText: '$M$test_pass_1' - } - } - } - }), - 'should match expected config' - )); - }); - - describe('.info()', () => { - it('should return info about current status (inactive instance)', () => { - assert.deepStrictEqual(instance.info(), { - currentCycle: { - cycleNo: 0, - endTimestamp: null, - qkview: {}, - retries: { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }, - startTimestamp: null - }, - demoMode: false, - disabled: false, - id: 'f5telemetry_default::System::iHealthPoller_1', - name: 'iHealthPoller_1', - nextFireDate: 'not set', - state: 'uninitialized', - stats: { - cycles: 0, - cyclesCompleted: 0, - qkviewCollectRetries: 0, - qkviewUploadRetries: 0, - qkviewsCollected: 0, - qkviewsUploaded: 0, - reportCollectRetries: 0, - reportsCollected: 0 - }, - timeUntilNextExecution: 'not available' - }, 'should match expected data'); - }); - - it('should return info about current status (after one polling cycle)', () => { - instance = IHealthPoller.create(instanceID, { name: instanceName, demo: true }); - return new Promise((resolve, reject) => { - instance.once('died', () => reject(new Error('Unexpectedly died'))); - instance.once('report', () => resolve(instance.info())); - instance.start().catch(reject); - }) - .then((info) => { - assert.deepNestedInclude(info, { - currentCycle: { - cycleNo: 1, - endTimestamp: info.currentCycle.endTimestamp, // not care - qkview: { - qkviewFile, - qkviewURI - }, - retries: { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }, - startTimestamp: info.currentCycle.startTimestamp // not care - }, - demoMode: true, - disabled: false, - state: 'processReport', - stats: { - cycles: 1, - cyclesCompleted: 0, // actually 1, but stopped earlier than reached 'completed' - qkviewsCollected: 1, - qkviewCollectRetries: 0, - qkviewsUploaded: 1, - qkviewUploadRetries: 0, - reportsCollected: 1, - reportCollectRetries: 0 - }, - timeUntilNextExecution: info.timeUntilNextExecution, // not care - id: 'f5telemetry_default::System::iHealthPoller_1', - name: 'iHealthPoller_1 (DEMO)' - }, 'should match expected data'); - }); - }); - }); - - describe('.start() & .stop() and polling cycle', () => { - const startPoller = (poller, options) => new Promise((resolve, reject) => { - options = options || {}; - poller.on('died', (error) => (error ? reject(error) : resolve())); - if (!options.waitTillDied) { - poller.on('completed', () => { - if (!options.waitTillCycleCompleted - || options.waitTillCycleCompleted <= poller.info().stats.cyclesCompleted) { - resolve(); - } - }); - } - poller.start().catch(reject); - }); - - it('should start just once', () => { - const startedSpy = sinon.spy(); - instance.on('started', startedSpy); - return Promise.all([instance.start(), instance.start()]) - .then(() => { - assert.strictEqual(startedSpy.callCount, 1, 'should emit "started" event just once'); - }); - }); - - it('should reject when started already', () => assert.isRejected( - instance.start() - .then(() => instance.start()), - /IHealthPollerFSM instance is active already/ - )); - - it('should not fail on attempt to stop inactive instance', () => { - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled yet'); - return instance.stop() - .then(() => { - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled'); - }); - }); - - it('should not fail on attempt to stop twice', () => { - const disablingSpy = sinon.spy(); - instance.on('disabling', disablingSpy); - return instance.start() - .then(() => Promise.all([instance.stop(), instance.stop()])) - .then(() => { - assert.strictEqual(disablingSpy.callCount, 1, 'should emit "disabling" event just once'); - }); - }); - - it('should stop on attempt so start and stop and the same time (start and stop)', () => Promise.all([ - instance.start().catch((error) => error), - instance.stop().catch((error) => error) - ]) - .then((results) => { - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled'); - assert.deepStrictEqual(results[1], undefined, 'should have no error on attempt to stop'); - assert.match(results[0], /Unable to start IHealthPollerFSM instance due unknown reason/, 'should reject with error on attempt to start once stopped'); - })); - // try different order of execution - it('should stop on attempt so start and stop and the same time (stop and start)', () => Promise.all([ - instance.stop().catch((error) => error), - instance.start().catch((error) => error) - ]) - .then((results) => { - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled'); - assert.deepStrictEqual(results[0], undefined, 'should have no error on attempt to stop'); - assert.match(results[1], /Unable to start IHealthPollerFSM instance due unknown reason/, 'should reject with error on attempt to start once stopped'); - })); - - it('should complete polling cycle in demo mode', () => { - instance = IHealthPoller.create(instanceID, { name: instanceName, demo: true }); - const psGetSpy = sinon.spy(persistentStorage.persistentStorage, 'get'); - const psRemoveSpy = sinon.spy(persistentStorage.persistentStorage, 'remove'); - const psSetSpy = sinon.spy(persistentStorage.persistentStorage, 'set'); - const startPromise = startPoller(instance, { waitTillDied: true }); - const diedPromise = instance.waitFor('died'); - return Promise.all([startPromise, diedPromise]) - .then(() => { - assert.deepStrictEqual(coreStub.persistentStorage.savedData.ihealth, undefined, 'should have no iHealth data written to PersistentStorage'); - assert.strictEqual(psGetSpy.callCount, 0, 'should not call persistentStorage.get() in demo mode'); - assert.strictEqual(psRemoveSpy.callCount, 0, 'should not call persistentStorage.remove() in demo mode'); - assert.strictEqual(psSetSpy.callCount, 0, 'should not call persistentStorage.set() in demo mode'); - - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled once stopped'); - assert.deepStrictEqual(instance.info().stats, { - cycles: 1, - cyclesCompleted: 1, - qkviewsCollected: 1, - qkviewCollectRetries: 0, - qkviewsUploaded: 1, - qkviewUploadRetries: 0, - reportsCollected: 1, - reportCollectRetries: 0 - }); - assert.deepStrictEqual(instance.info().currentCycle.retries, { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }); - const removeFileStub = ihealthStub.ihealthUtil.DeviceAPI.removeFile; - assert.deepStrictEqual(removeFileStub.getCall(removeFileStub.callCount - 1).args[0], qkviewFile, 'should remove downloaded Qkview file'); - }); - }); - - [ - { - state: 'collectQkview', - stub: 'ihealthUtil.QkviewManager.process' - }, - { - state: 'uploadQkview', - stub: 'ihealthUtil.IHealthManager.uploadQkview' - }, - { - state: 'collectReport', - stub: 'ihealthUtil.IHealthManager.fetchQkviewDiagnostics' - } - ].forEach((testConf) => { - testUtil.getCallableIt(testConf)(`should die with error in demo mode when unable to complete "${testConf.state}"`, () => { - instance = IHealthPoller.create(instanceID, { name: instanceName, demo: true }); - getByKey(ihealthStub, testConf.stub).rejects(new Error('expected error')); - - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return assert.isRejected( - startPoller(instance, { waitTillDied: true }), - `Max. number of retries for state "${testConf.state}" reached!` - ); - }); - }); - - it('should complete polling cycle in regular mode', () => { - const psGetSpy = sinon.spy(persistentStorage.persistentStorage, 'get'); - const psRemoveSpy = sinon.spy(persistentStorage.persistentStorage, 'remove'); - const psSetSpy = sinon.spy(persistentStorage.persistentStorage, 'set'); - - const qkviewHistory = []; - let qkviewCounter = 0; - ihealthStub.ihealthUtil.QkviewManager.process.callsFake(() => { - qkviewCounter += 1; - return Promise.resolve(`qkviewFile_${qkviewCounter}`); - }); - ihealthStub.ihealthUtil.IHealthManager.uploadQkview.callsFake((fname) => { - qkviewHistory.push(fname); - return Promise.resolve(`https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/${fname}/0000000`); - }); - ihealthStub.ihealthUtil.IHealthManager.isQkviewReportReady.callsFake((uri) => { - qkviewHistory.push(uri); - return Promise.resolve(true); - }); - - const reports = []; - let storageCopy; - - instance.on('report', (report) => reports.push(report)); - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance, { waitTillCycleCompleted: 2 }) // wait till 2 cycles will be completed - .then(() => { - assert.strictEqual(psGetSpy.callCount, 1, 'should call persistentStorage.get() only once'); - assert.strictEqual(psRemoveSpy.callCount, 0, 'should not call persistentStorage.remove() until stopped'); - assert.isAtLeast(psSetSpy.callCount, 1, 'should call persistentStorage.set() at least once'); - assert.isTrue(instance.isActive(), 'should be active'); - storageCopy = testUtil.deepCopy(coreStub.persistentStorage.savedData.ihealth); - - assert.isTrue(instance.isActive(), 'should be active'); - assert.isFalse(instance.isDisabled(), 'should not be disabled'); - const stopPromise = instance.stop(); - assert.isTrue(instance.isDisabled(), 'should be disabled'); - return stopPromise; - }) - .then(() => { - assert.isFalse(instance.isActive(), 'should be inactive'); - assert.isFalse(instance.isDisabled(), 'should not be disabled once stopped'); - assert.strictEqual(psRemoveSpy.callCount, 1, 'should call persistentStorage.remove() once stopped'); - assert.deepStrictEqual(coreStub.persistentStorage.savedData.ihealth[instance.storageKey], undefined, 'should remove data from persistentStorage'); - - const instanceData = storageCopy[instance.storageKey]; - assert.strictEqual(typeof instanceData.schedule.nextExecTime, 'number'); - assert.deepStrictEqual(instanceData.stats, { - cycles: 2, - cyclesCompleted: 1, // stopped earlier than data was saved to storage - qkviewsCollected: 2, - qkviewCollectRetries: 0, - qkviewsUploaded: 2, - qkviewUploadRetries: 0, - reportsCollected: 2, - reportCollectRetries: 0 - }); - assert.deepStrictEqual(instanceData.version, '2.0'); - assert.deepStrictEqual(instanceData.currentCycle.qkview, { - qkviewFile: 'qkviewFile_2', - qkviewURI: 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/qkviewFile_2/0000000', - reportProcessed: true - }); - assert.deepStrictEqual(instanceData.currentCycle.retries, { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }); - assert.deepStrictEqual(instance.info().stats, { - cycles: 2, - cyclesCompleted: 2, - qkviewsCollected: 2, - qkviewCollectRetries: 0, - qkviewsUploaded: 2, - qkviewUploadRetries: 0, - reportsCollected: 2, - reportCollectRetries: 0 - }); - assert.deepStrictEqual(instance.info().currentCycle.retries, { - qkviewCollect: 0, - qkviewUpload: 0, - reportCollect: 0 - }); - - const removedFiles = ihealthStub.ihealthUtil.DeviceAPI.removeFile.args.map((args) => args[0]); - assert.includeMembers( - removedFiles, - [ - 'qkviewFile_1', - 'qkviewFile_2' - ] - ); - - for (let i = 1; i < reports.length; i += 1) { - // interval window size is 3h (see declaration) - // min. delay between prev. start and cur. start >= 21h - assert.isAbove( - reports[i].additionalInfo.cycleStart - reports[i - 1].additionalInfo.cycleStart, - 21 * 60 * 60 * 1000 - 1, - 'should be above 21h' - ); - } - - assert.deepStrictEqual(qkviewHistory, [ - 'qkviewFile_1', - 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/qkviewFile_1/0000000', - 'qkviewFile_2', - 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/qkviewFile_2/0000000' - ]); - }); - }); - - it('should retry Qkview actions when failed', () => { - const getConfigStub = sinon.stub(IHealthPoller.prototype, 'getConfig'); - getConfigStub.onFirstCall().callsFake(() => { - getConfigStub.restore(); - return Promise.reject(new Error('expected getConfig error')); - }); - ihealthStub.ihealthUtil.QkviewManager.process.onFirstCall().rejects(new Error('expected Qkview collect error')); - ihealthStub.ihealthUtil.IHealthManager.uploadQkview.onFirstCall().rejects(new Error('expected Qkview upload error')); - ihealthStub.ihealthUtil.IHealthManager.isQkviewReportReady.onFirstCall().rejects(new Error('expected Report check error')); - ihealthStub.ihealthUtil.IHealthManager.fetchQkviewDiagnostics.onFirstCall().rejects(new Error('expected Report collect error')); - - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance) - .then(() => instance.stop()) - .then(() => { - assert.deepStrictEqual(instance.info().stats, { - cycles: 2, // getConfig makes scheduling to fail and start next cycle - cyclesCompleted: 1, - qkviewsCollected: 1, - qkviewCollectRetries: 1, // should retry just once - qkviewsUploaded: 1, - qkviewUploadRetries: 1, - reportsCollected: 1, - reportCollectRetries: 2 // 2 retries - report check and report fetch - }); - }); - }); - - it('should start new cycle when reached max number of retries for each Qkview action', () => { - // max num of retries for Qkview collect and upload is 5, total attempts 6 - for (let i = 0; i < 6; i += 1) { - ihealthStub.ihealthUtil.QkviewManager.process.onCall(i).rejects(new Error('expected Qkview collect error')); // cycle #1 - ihealthStub.ihealthUtil.IHealthManager.uploadQkview.onCall(i).rejects(new Error('expected Qkview upload error')); // cycle #2 - } - // max num of retries for Qkview collect and upload is 30, total attempts 31 - for (let i = 0; i < 31; i += 1) { - ihealthStub.ihealthUtil.IHealthManager.isQkviewReportReady.onCall(i).rejects(new Error('expected Report check error')); // cycle #3 - ihealthStub.ihealthUtil.IHealthManager.fetchQkviewDiagnostics.onCall(i).rejects(new Error('expected Report collect error')); // cycle #4 - } - - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance) - .then(() => instance.stop()) - .then(() => { - assert.deepStrictEqual(instance.info().stats, { - cycles: 5, - cyclesCompleted: 1, // only 1 successfully completed cycle - qkviewsCollected: 4, - qkviewCollectRetries: 5, - qkviewsUploaded: 3, - qkviewUploadRetries: 5, - reportsCollected: 1, - reportCollectRetries: 60 - }); - }); - }); - - it('should retry when Qkview report is not ready', () => { - ihealthStub.ihealthUtil.IHealthManager.isQkviewReportReady.onFirstCall().resolves(false); - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance) - .then(() => instance.stop()) - .then(() => { - assert.deepStrictEqual(instance.info().stats, { - cycles: 1, - cyclesCompleted: 1, // only 1 successfully completed cycle - qkviewsCollected: 1, - qkviewCollectRetries: 0, - qkviewsUploaded: 1, - qkviewUploadRetries: 0, - reportsCollected: 1, - reportCollectRetries: 1 - }); - }); - }); - - it('should restore state', () => { - const instanceStorageKey = instance.storageKey; - const reportEventSpy = sinon.spy(); - - let clockStub; - let fetchQkviewDiagnosticsCallCount; - let isQkviewReportReadyCallCount; - let lastSavedData; - let nextExecTime; - let processQkviewCallCount; - let reportEventSpyCallCount; - let timeToRestore; - let uploadQkviewCallCount; - - const stopOnceDataSaved = (state) => new Promise((resolve) => { - coreStub.persistentStorage.saveCbAfter = (ctx) => { - if (ctx.savedData.ihealth[instance.storageKey].lastKnownState === state) { - lastSavedData = testUtil.deepCopy(ctx.savedData); - resolve(); - } - }; - }) - .then(() => IHealthPoller.disable(instance)) - .then((ret) => ret.stopPromise) - .then(() => { - instance = null; - }); - - const restoreDataAndStart = (inst, fakeTime) => { - // need to re-set time to avoid scheduling next execution - if (clockStub) { - clockStub.stub.restore(); - } - clockStub = stubs.clock({ fakeTimersOpts: timeToRestore || new Date('Mon, 15 Mar 2021 19:00:50 GMT').getTime() }); - if (fakeTime !== false) { - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); // 30 min. in advance - } - coreStub.persistentStorage.loadData = lastSavedData; - return persistentStorage.persistentStorage.load() - .then(() => { - instance = inst || IHealthPoller.create(instanceID, { name: instanceName }); - instance.on('report', reportEventSpy); - return instance.start(); - }); - }; - - return Promise.all([restoreDataAndStart(instance, false), stopOnceDataSaved('schedule')]) - .then(() => { - nextExecTime = lastSavedData.ihealth[instanceStorageKey].schedule.nextExecTime; - assert.strictEqual(typeof nextExecTime, 'number', 'should be number'); - assert.isAbove(nextExecTime, 0, 'should be greater than 0'); - timeToRestore = nextExecTime + 3 * 60 * 60 * 1000; // exec time + 3h to trigger re-schedule - return Promise.all([ - restoreDataAndStart(instance, false), // should restore to 'schedule' state - stopOnceDataSaved('schedule') - ]); - }) - .then(() => { - assert.strictEqual(ihealthStub.ihealthUtil.QkviewManager.process.callCount, 0, 'should not call QkviewManager.process'); - assert.isAbove(lastSavedData.ihealth[instanceStorageKey].schedule.nextExecTime, nextExecTime, 'should re-schedule'); - - nextExecTime = lastSavedData.ihealth[instanceStorageKey].schedule.nextExecTime; - assert.strictEqual(typeof nextExecTime, 'number', 'should be number'); - assert.isAbove(nextExecTime, 0, 'should be greater than 0'); - return Promise.all([ - restoreDataAndStart(instance), // should restore to 'schedule' state - stopOnceDataSaved('collectQkview') - ]); - }) - .then(() => { - processQkviewCallCount = ihealthStub.ihealthUtil.QkviewManager.process.callCount; - assert.isAbove(processQkviewCallCount, 0, 'should call QkviewManager.process at least once'); - assert.deepStrictEqual(lastSavedData.ihealth[instanceStorageKey].schedule.nextExecTime, nextExecTime, 'should not re-schedule'); - return Promise.all([ - restoreDataAndStart(instance), // should restore to 'collectQkview' state - stopOnceDataSaved('uploadQkview') - ]); - }) - .then(() => { - uploadQkviewCallCount = ihealthStub.ihealthUtil.IHealthManager.uploadQkview.callCount; - assert.strictEqual(ihealthStub.ihealthUtil.QkviewManager.process.callCount, processQkviewCallCount, 'should not call QkviewManager.process once restored'); - assert.isAbove(uploadQkviewCallCount, 0, 'should call IHealthManager.uploadQkview at least once'); - return Promise.all([ - restoreDataAndStart(instance), - stopOnceDataSaved('collectReport') // should restore to 'uploadQkview' state - ]); - }) - .then(() => { - // force to re-fetch diag. data - lastSavedData.ihealth[instanceStorageKey].currentCycle.qkview.reportProcessed = false; - - fetchQkviewDiagnosticsCallCount = ihealthStub.ihealthUtil - .IHealthManager.fetchQkviewDiagnostics.callCount; - isQkviewReportReadyCallCount = ihealthStub.ihealthUtil - .IHealthManager.isQkviewReportReady.callCount; - assert.isAbove(isQkviewReportReadyCallCount, 0, 'should call IHealthManager.isQkviewReportReady at least once'); - assert.isAbove(fetchQkviewDiagnosticsCallCount, 0, 'should call IHealthManager.fetchQkviewDiagnostics at least once'); - assert.strictEqual(ihealthStub.ihealthUtil.IHealthManager.uploadQkview.callCount, uploadQkviewCallCount, 'should not call IHealthManager.uploadQkview once restored'); - return Promise.all([ - restoreDataAndStart(instance), // should restore to 'collectReport' state - stopOnceDataSaved('processReport') - ]); - }) - .then(() => { - // should call .fetchQkviewDiagnosticsCall and .isQkviewReportReady to obtain data - // from iHealth again and send an event - assert.isAbove(ihealthStub.ihealthUtil - .IHealthManager.fetchQkviewDiagnostics.callCount, fetchQkviewDiagnosticsCallCount, 'should call IHealthManager.fetchQkviewDiagnosticsCall once restored'); - assert.isAbove(ihealthStub.ihealthUtil - .IHealthManager.isQkviewReportReady.callCount, isQkviewReportReadyCallCount, 'should call IHealthManager.isQkviewReportReady once restored'); - assert.isAbove(reportEventSpy.callCount, 1, 'should emit "report" at least once'); - - reportEventSpyCallCount = reportEventSpy.callCount; - // sub-test: should not try to obtain dia. report if it was processed already - fetchQkviewDiagnosticsCallCount = ihealthStub.ihealthUtil - .IHealthManager.fetchQkviewDiagnostics.callCount; - isQkviewReportReadyCallCount = ihealthStub.ihealthUtil - .IHealthManager.isQkviewReportReady.callCount; - - return Promise.all([ - restoreDataAndStart(instance), // should restore to 'collectReport' state - stopOnceDataSaved('schedule') - ]); - }) - .then(() => { - assert.strictEqual(reportEventSpy.callCount, reportEventSpyCallCount, 'should not emit "report" once restored'); - assert.strictEqual(ihealthStub.ihealthUtil - .IHealthManager.fetchQkviewDiagnostics.callCount, fetchQkviewDiagnosticsCallCount, 'should not call IHealthManager.fetchQkviewDiagnosticsCall once restored'); - assert.strictEqual(ihealthStub.ihealthUtil - .IHealthManager.isQkviewReportReady.callCount, isQkviewReportReadyCallCount, 'should not call IHealthManager.isQkviewReportReady once restored'); - }); - }); - - it('should pass configuration to QkviewManager and IHealthManager (full config)', () => { - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance) - .then(() => instance.stop()) - .then(() => { - const ihmArgs = ihealthStub.ihealthUtil.IHealthManager.constructor.args[0]; - const qkmArgs = ihealthStub.ihealthUtil.QkviewManager.constructor.args[0]; - - assert.deepStrictEqual(ihmArgs[0], { - username: 'test_user_2', - passphrase: 'test_pass_2' - }, 'should pass decrypted iHealth credentials'); - assert.deepStrictEqual(ihmArgs[1].proxy, { - connection: { - allowSelfSignedCert: true, - host: '192.168.100.1', - port: 443, - protocol: 'https' - }, - credentials: { - username: 'test_user_3', - passphrase: 'test_pass_3' - } - }, 'should pass decrypted iHealth proxy options'); - assert.strictEqual(qkmArgs[0], '192.168.0.1', 'should match system host'); - assert.deepStrictEqual(qkmArgs[1].connection, { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }, 'should match system\'s connection options'); - assert.deepStrictEqual(qkmArgs[1].credentials, { - username: 'test_user_1', - passphrase: 'test_pass_1' - }, 'should match system\'s credentials'); - assert.deepStrictEqual(qkmArgs[1].downloadFolder, './', 'should match configured download folder'); - }); - }); - - it('should pass configuration to QkviewManager and IHealthManager (minimal config)', () => { - declaration = dummies.declaration.base.decrypted(); - declaration.System = dummies.declaration.system.minimal.decrypted({}); - declaration.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineMinimal.decrypted({ - username: 'test_user_2', - passphrase: { cipherText: 'test_pass_2' } - }); - return configWorker.processDeclaration(declaration) - .then(() => { - const clockStub = stubs.clock(); - clockStub.clockForward(30 * 60 * 1000, { promisify: true }); - return startPoller(instance); - }) - .then(() => instance.stop()) - .then(() => { - const ihmArgs = ihealthStub.ihealthUtil.IHealthManager.constructor.args[0]; - const qkmArgs = ihealthStub.ihealthUtil.QkviewManager.constructor.args[0]; - - assert.deepStrictEqual(ihmArgs[0], { - username: 'test_user_2', - passphrase: 'test_pass_2' - }, 'should pass decrypted iHealth credentials'); - assert.deepStrictEqual(ihmArgs[1].proxy, { - connection: { - allowSelfSignedCert: false, - host: undefined, - port: undefined, - protocol: undefined - }, - credentials: { - username: undefined, - passphrase: undefined - } - }, 'should pass decrypted proxy options'); - assert.strictEqual(qkmArgs[0], 'localhost', 'should match system host'); - assert.deepStrictEqual(qkmArgs[1].connection, { - port: 8100, - protocol: 'http', - allowSelfSignedCert: false - }, 'should match system\'s connection options'); - assert.deepStrictEqual(qkmArgs[1].credentials, { - username: undefined, - passphrase: undefined - }, 'should match system\'s credentials'); - assert.deepStrictEqual(qkmArgs[1].downloadFolder, '/shared/tmp', 'should match default download folder'); - }); - }); - }); - }); -}); diff --git a/test/unit/loggerTests.js b/test/unit/loggerTests.js index c05de797..c4f6e53e 100644 --- a/test/unit/loggerTests.js +++ b/test/unit/loggerTests.js @@ -140,45 +140,55 @@ describe('Logger', () => { it('should log an exception', () => { const msgType = 'exception'; - logger.exception(`this is a ${msgType} message`, new Error('foo')); - logger.debugException(`this is a ${msgType} message`, new Error('foo')); - logger.verboseException(`this is a ${msgType} message`, new Error('foo')); + const error = new Error('foo'); + + logger.exception(`this is a ${msgType} message`, error); + logger.debugException(`this is a ${msgType} message`, error); + logger.verboseException(`this is a ${msgType} message`, error); assert.lengthOf(coreStub.logger.messages.error, 1); assert.isEmpty(coreStub.logger.messages.debug); - assert.include(coreStub.logger.messages.error[0], `this is a ${msgType} message`); + assert.include(coreStub.logger.messages.error[0], `this is a ${msgType} message\nMessage: foo\nTraceback:`); logger.setLogLevel('debug'); - logger.exception(`this is a ${msgType} message`, new Error('foo')); + logger.exception(`this is a ${msgType} message`, error); logger.exception(`this is a ${msgType} message`); - logger.debugException(`this is a ${msgType} message [debug]`, new Error('foo')); + logger.debugException(`this is a ${msgType} message [debug]`, error); logger.debugException(`this is a ${msgType} message [debug]`); - logger.verboseException(`this is a ${msgType} message [verbose]`, new Error('foo')); + logger.verboseException(`this is a ${msgType} message [verbose]`, error); logger.verboseException(`this is a ${msgType} message [verbose]`); assert.lengthOf(coreStub.logger.messages.error, 3); assert.lengthOf(coreStub.logger.messages.debug, 2); - assert.include(coreStub.logger.messages.error[1], `this is a ${msgType} message`); - assert.include(coreStub.logger.messages.error[2], `this is a ${msgType} message\nTraceback:\nno traceback available`); - assert.include(coreStub.logger.messages.debug[0], `this is a ${msgType} message [debug]`); - assert.include(coreStub.logger.messages.debug[1], `this is a ${msgType} message [debug]\nTraceback:\nno traceback available`); + assert.include(coreStub.logger.messages.error[1], `this is a ${msgType} message\nMessage: foo\nTraceback:`); + assert.include(coreStub.logger.messages.error[2], `this is a ${msgType} message\nMessage: no message available\nTraceback:\nno traceback available`); + assert.include(coreStub.logger.messages.debug[0], `this is a ${msgType} message [debug]\nMessage: foo\nTraceback:`); + assert.include(coreStub.logger.messages.debug[1], `this is a ${msgType} message [debug]\nMessage: no message available\nTraceback:\nno traceback available`); logger.setLogLevel('verbose'); - logger.exception(`this is a ${msgType} message`, new Error('foo')); + logger.exception(`this is a ${msgType} message`, error); logger.exception(`this is a ${msgType} message`); - logger.debugException(`this is a ${msgType} message [debug]`, new Error('foo')); + logger.debugException(`this is a ${msgType} message [debug]`, error); logger.debugException(`this is a ${msgType} message [debug]`); - logger.verboseException(`this is a ${msgType} message [verbose]`, new Error('foo')); + logger.verboseException(`this is a ${msgType} message [verbose]`, error); logger.verboseException(`this is a ${msgType} message [verbose]`); assert.lengthOf(coreStub.logger.messages.error, 5); assert.lengthOf(coreStub.logger.messages.debug, 6); - assert.include(coreStub.logger.messages.error[3], `this is a ${msgType} message`); - assert.include(coreStub.logger.messages.error[4], `this is a ${msgType} message\nTraceback:\nno traceback available`); - assert.include(coreStub.logger.messages.debug[2], `this is a ${msgType} message [debug]`); - assert.include(coreStub.logger.messages.debug[3], `this is a ${msgType} message [debug]\nTraceback:\nno traceback available`); - assert.include(coreStub.logger.messages.debug[4], `this is a ${msgType} message [verbose]`); - assert.include(coreStub.logger.messages.debug[5], `this is a ${msgType} message [verbose]\nTraceback:\nno traceback available`); + assert.include(coreStub.logger.messages.error[3], `this is a ${msgType} message\nMessage: foo\nTraceback:`); + assert.include(coreStub.logger.messages.error[4], `this is a ${msgType} message\nMessage: no message available\nTraceback:\nno traceback available`); + assert.include(coreStub.logger.messages.debug[2], `this is a ${msgType} message [debug]\nMessage: foo\nTraceback:`); + assert.include(coreStub.logger.messages.debug[3], `this is a ${msgType} message [debug]\nMessage: no message available\nTraceback:\nno traceback available`); + assert.include(coreStub.logger.messages.debug[4], `this is a ${msgType} message [verbose]\nMessage: foo\nTraceback:`); + assert.include(coreStub.logger.messages.debug[5], `this is a ${msgType} message [verbose]\nMessage: no message available\nTraceback:\nno traceback available`); + + error.message = 'my custom message'; + logger.exception(`this is a ${msgType} message`, error); + logger.debugException(`this is a ${msgType} message [debug]`, error); + logger.verboseException(`this is a ${msgType} message [verbose]`, error); + assert.include(coreStub.logger.messages.error[5], `this is a ${msgType} message\nMessage: my custom message\nTraceback:\nError: foo`); + assert.include(coreStub.logger.messages.debug[6], `this is a ${msgType} message [debug]\nMessage: my custom message\nTraceback:\nError: foo`); + assert.include(coreStub.logger.messages.debug[7], `this is a ${msgType} message [verbose]\nMessage: my custom message\nTraceback:\nError: foo`); }); it('should stringify object', () => { diff --git a/test/unit/persistentStorageTests.js b/test/unit/persistentStorageTests.js deleted file mode 100644 index 2afbe5fb..00000000 --- a/test/unit/persistentStorageTests.js +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const deepCopy = require('./shared/util').deepCopy; -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); - -const persistentStorage = sourceCode('src/lib/persistentStorage'); - -moduleCache.remember(); - -describe('Persistent Storage', () => { - let persistentStorageInst; - let persistentStorageStub; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - persistentStorageInst = persistentStorage.persistentStorage; - persistentStorageStub = stubs.persistentStorage(persistentStorage); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('persistentStorage', () => { - describe('.get()', () => { - beforeEach(() => { - persistentStorageStub.loadData = { - somekey: [1, 2, 3, 4, 5] - }; - }); - - it('should return data copy on attempt to \'get\' it', () => persistentStorageInst.load() - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - value.push(6); - return persistentStorageInst.get('somekey'); - }) - .then((value) => assert.deepStrictEqual(value, [1, 2, 3, 4, 5]))); - - it('should return data (one level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, [1, 2, 3, 4, 5]))); - - it('should return data (multi level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.get('somekey[1]')) - .then((value) => assert.deepStrictEqual(value, 2))); - - it('should return data (multi level key as array)', () => { - persistentStorageStub.loadData = { - somekey: { - 'key.with.dot': 10 - } - }; - return persistentStorageInst.load() - .then(() => persistentStorageInst.get(['somekey', 'key.with.dot'])) - .then((value) => assert.deepStrictEqual(value, 10)); - }); - }); - - describe('.set()', () => { - it('should return make data copy on attempt to \'save\' it', () => persistentStorageInst.load() - .then(() => { - const data = [1]; - const promise = persistentStorageInst.set('somekey', data); - data.push(2); - return promise; - }) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, [1]))); - - it('should set data (one level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey', 1)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, 1))); - - it('should set data (multi level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey.first.second', 10)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, { first: { second: 10 } }))); - - it('should set data (multi level key as array)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set(['somekey', 'first', 'second'], 10)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, { first: { second: 10 } }))); - - it('should set data (multiple requests)', () => { - persistentStorageStub.loadData = {}; - return persistentStorageInst.load() - .then(() => Promise.all([ - persistentStorageInst.set('a.a', 10), - persistentStorageInst.set('a.b', 20) - ])) - .then(() => Promise.all([ - persistentStorageInst.get('a.a'), - persistentStorageInst.get('a.b') - ])) - .then((results) => assert.deepStrictEqual(results, [10, 20])) - .then(() => persistentStorageInst.get('a')) - .then((value) => assert.deepStrictEqual(value, { a: 10, b: 20 })); - }); - - it('should override data (multiple requests)', () => { - persistentStorageStub.loadData = {}; - let counter = 0; - const getAndSet = () => persistentStorageInst.get('data') - .then((value) => { - if (typeof value === 'undefined') { - value = {}; - } - value[counter] = counter; - counter += 1; - return persistentStorageInst.set('data', value); - }); - return Promise.all([getAndSet(), getAndSet()]) - .then(() => persistentStorageInst.get('data')) - .then((data) => { - assert.lengthOf(Object.keys(data), 1); - }); - }); - }); - - describe('.remove()', () => { - it('should remove data (one level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey.first.second', 10)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - assert.deepStrictEqual(value, { first: { second: 10 } }); - return persistentStorageInst.remove('somekey'); - }) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, undefined))); - - it('should remove data (multi level key)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey.first.second', 10)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - assert.deepStrictEqual(value, { first: { second: 10 } }); - return persistentStorageInst.remove('somekey.first.second'); - }) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, { first: { } }))); - - it('should remove data (multi level key as array)', () => persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey.first.second', 10)) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - assert.deepStrictEqual(value, { first: { second: 10 } }); - return persistentStorageInst.remove(['somekey', 'first', 'second']); - }) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => assert.deepStrictEqual(value, { first: { } }))); - }); - }); - - describe('RestStorage', () => { - describe('.get()', () => { - it('should return undefined when key doesn\'t exists', () => assert.becomes( - persistentStorageInst.load().then(() => persistentStorageInst.get('undefinedkey')), - undefined - )); - - it('should return data when key exists', () => assert.becomes( - persistentStorageInst.load() - .then(() => persistentStorageInst.set('someKey', 'someData')) - .then(() => persistentStorageInst.get('someKey')), - 'someData' - )); - }); - - describe('.load()', () => { - it('should fail to load when restWorker returns error', () => { - persistentStorageStub.loadError = new Error('loadStateError'); - return assert.isRejected(persistentStorageInst.load(), /loadStateError/); - }); - - it('should fail to load when restWorker throws error', () => { - persistentStorageStub.loadCbBefore = () => { throw new Error('loadStateError'); }; - return assert.isRejected(persistentStorageInst.load(), /loadStateError/); - }); - - it('should set _data_ to object when loaded null', () => { - persistentStorageStub.loadData = null; - return persistentStorageInst.load() - .then((state) => { - assert.deepStrictEqual(state, {}); - assert.deepStrictEqual(persistentStorageInst.storage._cache, { _data_: {} }); - }); - }); - - it('should load empty state', () => { - persistentStorageStub.loadState = null; - return persistentStorageInst.load() - .then((state) => { - assert.deepStrictEqual(state, {}); - assert.deepStrictEqual(persistentStorageInst.storage._cache, { _data_: {} }); - }); - }); - - it('should fail to load when no restWorker provided', () => { - persistentStorageInst.storage.restWorker = null; - assert.throws( - () => persistentStorageInst.load(), - /restWorker is not specified/ - ); - }); - - it('should load pre-existing state (old-version)', () => { - persistentStorageStub.loadState = { - config: { - key: 'somedata' - } - }; - return assert.becomes(persistentStorageInst.load(), { - config: { - key: 'somedata' - } - }); - }); - - it('should load pre-existing state (new-version)', () => { - persistentStorageStub.loadState = { - _data_: { - somekey: 'somedata' - } - }; - return persistentStorageInst.load() - .then((state) => { - assert.deepStrictEqual(persistentStorageInst.storage._cache, { - _data_: { - somekey: 'somedata' - } - }); - assert.deepStrictEqual(state, { somekey: 'somedata' }); - }); - }); - }); - - describe('.remove()', () => { - it('should fail to remove when restWorker returns error', () => { - persistentStorageStub.saveError = new Error('removeDataError'); - return assert.isRejected( - persistentStorageInst.load() - .then(() => persistentStorageInst.remove('somekey')), - /removeDataError/ - ); - }); - - it('should not fail when key doesn\'t exists', () => assert.becomes( - persistentStorageInst.load() - .then(() => persistentStorageInst.remove('someKey')) - .then(() => persistentStorageInst.get('someKey')), - undefined - )); - - it('should remove data when key exists', () => assert.becomes( - persistentStorageInst.load() - .then(() => persistentStorageInst.set('someKey', 'someData')) - .then(() => persistentStorageInst.get('someKey')), - 'someData' - )); - }); - - describe('.save()', () => { - it('should fail to save when restWorker returns error', () => { - persistentStorageStub.saveError = new Error('saveStateError'); - return assert.isRejected( - persistentStorageInst.load() - .then(() => persistentStorageInst.save()), - /saveStateError/ - ); - }); - - it('should fail to save when restWorker throws error', () => { - persistentStorageStub.saveCbBefore = () => { throw new Error('saveStateError'); }; - return assert.isRejected(persistentStorageInst.save(), /saveStateError/); - }); - - it('should save loaded state', () => assert.isFulfilled(persistentStorageInst.load() - .then(() => persistentStorageInst.save()))); - - it('should fail to save when no restWorker provided', () => { - persistentStorageInst.storage.restWorker = null; - assert.throws( - () => persistentStorageInst.save(), - /restWorker is not specified/ - ); - }); - - it('should fail to save null state', () => { - persistentStorageInst.storage._cache = null; - assert.throws( - () => persistentStorageInst.save(), - /no loaded state/ - ); - }); - - it('should queue \'save\' operation if current \'save\' in progress', () => { - persistentStorageStub.saveCbBefore = () => { - // trigger next 'save' op and it will be queued - // because we are in the middle of prev. 'save' op. - persistentStorageInst.save(); - delete persistentStorageStub.saveCbBefore; - }; - return persistentStorageInst.load() // save #1 - .then(() => persistentStorageInst.save()) - .then(() => { - assert.strictEqual(persistentStorageStub.restWorker.saveState.callCount, 2); - }); - }); - - it('should fail to save when unable to copy data', () => { - persistentStorageInst.storage._cache = { - _data_: {} - }; - persistentStorageInst.storage._cache._data_.cache = persistentStorageInst.storage._cache; - return assert.isRejected(persistentStorageInst.save(), /Converting circular structure to JSON/); - }); - }); - - describe('.set()', () => { - it('should fail to set when restWorker returns error', () => { - persistentStorageStub.saveError = new Error('setDataError'); - return assert.isRejected( - persistentStorageInst.load() - .then(() => persistentStorageInst.set('somekey', 'somedata')), - /setDataError/ - ); - }); - }); - - describe('.load() & .save() mix', () => { - it('should preserve service properties on load and save', () => { - const expectedState = { - _data_: { - somekey: 'somedata' - }, - sp1: 200, - sp2: 300 - }; - const loadState = { - _data_: { - somekey: 'somedata' - }, - sp1: 100, - sp2: 200 - }; - persistentStorageStub.loadState = deepCopy(loadState); - persistentStorageStub.saveCbBefore = (ctx, first, state) => { - // sometimes RestWorker backend sets some data required for further work - state.sp1 = 200; - state.sp2 = 300; - }; - return persistentStorageInst.load() - .then((state) => { - assert.deepStrictEqual(persistentStorageInst.storage._cache, loadState); - assert.deepStrictEqual(state, loadState._data_); - return persistentStorageInst.save(); - }) - .then(() => { - // cache should be updated with 'service data' - const cache = persistentStorageInst.storage._cache; - assert.deepStrictEqual(cache, expectedState); - assert.deepStrictEqual(cache._data_, expectedState._data_); - }); - }); - - it('should save only once in current event cycle', () => persistentStorageInst.load() - .then(() => Promise.all([ - persistentStorageInst.set('somekey', 1), // #1 - persistentStorageInst.set('somekey', 2), // #2, overrides #1 - persistentStorageInst.set('somekey', 3), // #3, overrides #2 - persistentStorageInst.set('somekey', 'expectedValue') // #4, overrides #3 - ])) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - assert.strictEqual(persistentStorageStub.restWorker.saveState.callCount, 1); - assert.strictEqual(value, 'expectedValue'); - })); - - it('should load only once in current event cycle', () => persistentStorageInst.load() // load #1 - .then(() => Promise.all([ // load #2 - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load() - ])) - .then(() => Promise.all([ // load #3 - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load(), - persistentStorageInst.load() - ])) - .then(() => { - assert.strictEqual(persistentStorageStub.restWorker.loadState.callCount, 3); - })); - - it('should be able to save data after previous attempt', () => persistentStorageInst.load() // load #1 - .then(() => Promise.all([ // save #1 - persistentStorageInst.set('somekey', 1), - persistentStorageInst.set('somekey', 2), - persistentStorageInst.set('somekey', 3), - persistentStorageInst.set('somekey', 4) - ])) - .then(() => Promise.all([ // save #2 - persistentStorageInst.set('somekey', 1), - persistentStorageInst.set('somekey', 2), - persistentStorageInst.set('somekey', 10), - persistentStorageInst.set('somekey', 'expectedValue') - ])) - .then(() => persistentStorageInst.get('somekey')) - .then((value) => { - assert.strictEqual(value, 'expectedValue'); - assert.strictEqual(persistentStorageStub.restWorker.saveState.callCount, 2); - })); - - it('should be able to save / load in current cycle', () => persistentStorageInst.load() // load #1 - .then(() => Promise.all([ // load #2, save #1 - persistentStorageInst.set('somekey1', 1), - persistentStorageInst.set('somekey2', 2), - persistentStorageInst.set('somekey3', 3), - persistentStorageInst.load(), - persistentStorageInst.set('somekey4', 4), - persistentStorageInst.load(), - persistentStorageInst.set('somekey5', 6), - persistentStorageInst.load(), - persistentStorageInst.set('somekey6', 1) - ])) - .then(() => { - assert.strictEqual(persistentStorageStub.restWorker.saveState.callCount, 1); - assert.strictEqual(persistentStorageStub.restWorker.loadState.callCount, 2); - })); - - it('should preserve load-save order', () => { - const history = []; - persistentStorageStub.loadCbBefore = () => history.push('load'); - persistentStorageStub.saveCbBefore = () => history.push('save'); - persistentStorageInst.load(); // load #1 - persistentStorageInst.save(); // save #1 - persistentStorageInst.load(); // load #1 - persistentStorageInst.save(); // save #1 - return persistentStorageInst.save() // save #1 - .then(() => { - assert.deepStrictEqual(history, ['load', 'save']); - }); - }); - - it('should preserve save-load order', () => { - const history = []; - persistentStorageStub.loadCbBefore = () => history.push('load'); - persistentStorageStub.saveCbBefore = () => history.push('save'); - persistentStorageInst.save(); // load #1 - persistentStorageInst.load(); // save #1 - persistentStorageInst.save(); // load #1 - persistentStorageInst.load(); // save #1 - return persistentStorageInst.load() // save #1 - .then(() => { - assert.deepStrictEqual(history, ['save', 'load']); - }); - }); - }); - }); -}); diff --git a/test/unit/propertiesJsonTests.js b/test/unit/propertiesJsonTests.js deleted file mode 100644 index 79a84bd2..00000000 --- a/test/unit/propertiesJsonTests.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const nock = require('nock'); - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const testUtil = require('./shared/util'); - -const defaultPaths = sourceCode('src/lib/paths.json'); -const defaultProperties = sourceCode('src/lib/properties.json'); -const SystemStats = sourceCode('src/lib/systemStats'); - -const pathsStateValidator = testUtil.getSpoiledDataValidator(defaultPaths); -const propertiesStateValidator = testUtil.getSpoiledDataValidator(defaultProperties); -const testsDataPath = 'test/unit/data/propertiesJsonTests'; - -moduleCache.remember(); - -describe('properties.json', () => { - before(() => { - moduleCache.restore(); - }); - - const TOTAL_ATTEMPTS = 10; - - const checkResponse = (endpointMock, response) => { - if (!response.kind) { - throw new Error(`Endpoint '${endpointMock.endpoint}' has no property 'kind' in response`); - } - }; - - const copyProperties = (properties, keys) => { - const ret = {}; - keys.forEach((key) => { - if (typeof properties[key] === 'undefined') { - throw new Error(`copyProperties: unknown key "${key}"`); - } - ret[key] = properties[key]; - }); - return ret; - }; - - const generateProperties = (source, data) => { - let ret; - if (Array.isArray(data)) { - ret = copyProperties(source, data); - } else if (typeof data === 'function') { - ret = data(source); - } - return ret; - }; - - const loadedTestsData = testUtil.loadModules(testsDataPath); - Object.keys(loadedTestsData).forEach((fileName) => { - const testSet = loadedTestsData[fileName]; - testUtil.getCallableDescribe(testSet)(testSet.name, () => { - afterEach(() => { - nock.cleanAll(); - }); - - testSet.tests.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => { - const contextToCollect = generateProperties(defaultProperties.context, testConf.contextToCollect) - || defaultProperties.context; - const statsToCollect = generateProperties(defaultProperties.stats, testConf.statsToCollect) - || defaultProperties.stats; - - const options = { - defaultPaths, - properties: { - stats: statsToCollect, - context: contextToCollect, - global: defaultProperties.global, - definitions: defaultProperties.definitions - }, - dataOpts: { - tags: { - tenant: '`T`', - application: '`A`' - } - } - }; - - const getCollectedData = testConf.getCollectedData - ? testConf.getCollectedData : (promise) => promise; - - const stats = new SystemStats(options); - - let promise = Promise.resolve(); - for (let i = 1; i < TOTAL_ATTEMPTS + 1; i += 1) { - promise = promise.then(() => { - testUtil.mockEndpoints(testConf.endpoints || [], { responseChecker: checkResponse }); - return getCollectedData(stats.collect(), stats); - }) - .then((data) => { - assert.deepStrictEqual(data, testConf.expectedData, `should match expected output (attempt #${i}`); - assert.isEmpty(stats.loader.cachedResponse, `cache should be erased (attempt #${i}`); - - pathsStateValidator(); - propertiesStateValidator(); - }); - } - return promise; - }); - }); - }); - }); -}); diff --git a/test/unit/pullConsumers/defaultPullConsumerTests.js b/test/unit/pullConsumers/defaultPullConsumerTests.js index ecf56fa9..e2412a13 100644 --- a/test/unit/pullConsumers/defaultPullConsumerTests.js +++ b/test/unit/pullConsumers/defaultPullConsumerTests.js @@ -25,11 +25,11 @@ const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); const testUtil = require('../shared/util'); -const defaultConsumer = sourceCode('src/lib/pullConsumers/default'); +const defaultConsumer = sourceCode('src/lib/consumers/default'); moduleCache.remember(); -describe('Default Pull Consumer', () => { +describe.skip('Default Pull Consumer', () => { const context = { event: [[{}]], config: {}, diff --git a/test/unit/pullConsumers/prometheusPullConsumerTests.js b/test/unit/pullConsumers/prometheusPullConsumerTests.js index 5ad8d9d6..2a62d220 100644 --- a/test/unit/pullConsumers/prometheusPullConsumerTests.js +++ b/test/unit/pullConsumers/prometheusPullConsumerTests.js @@ -28,13 +28,13 @@ const sourceCode = require('../shared/sourceCode'); const SYSTEM_POLLER_DATA = require('./data/system_poller_datasets.json'); const testUtil = require('../shared/util'); -const prometheusConsumer = sourceCode('src/lib/pullConsumers/Prometheus'); +const prometheusConsumer = sourceCode('src/lib/consumers/Prometheus'); moduleCache.remember(); const arraysToPromLines = (input) => lodash.flatten(input).join('\n'); -describe('Prometheus Pull Consumer', () => { +describe.skip('Prometheus Pull Consumer', () => { let context; let eventStub; diff --git a/test/unit/pullConsumersTests.js b/test/unit/pullConsumersTests.js deleted file mode 100644 index 54627fca..00000000 --- a/test/unit/pullConsumersTests.js +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const pullConsumersTestsData = require('./data/pullConsumersTestsData'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); -const testUtil = require('./shared/util'); - -const configWorker = sourceCode('src/lib/config'); -const configUtil = sourceCode('src/lib/utils/config'); -const CONFIG_CLASSES = sourceCode('src/lib/constants').CONFIG_CLASSES; -const moduleLoader = sourceCode('src/lib/utils/moduleLoader').ModuleLoader; -const pullConsumers = sourceCode('src/lib/pullConsumers'); -const systemPoller = sourceCode('src/lib/systemPoller'); - -moduleCache.remember(); - -describe('Pull Consumers', () => { - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - stubs.default.coreStub(); - stubs.clock(); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('config listener', () => { - it('should load required pull consumer type (consumerType=default)', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, - type: 'default', - systemPoller: 'My_Poller' - }, - My_Poller: { - class: CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME - } - }; - return configWorker.processDeclaration(exampleConfig) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.lengthOf(loadedConsumers, 1); - assert.deepStrictEqual(loadedConsumers[0].config.type, 'default', 'should load default consumer'); - }); - }); - - it('should not load disabled pull consumer', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, - type: 'default', - systemPoller: 'My_Poller', - enable: false - }, - My_Poller: { - class: CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME - } - }; - return configWorker.processDeclaration(exampleConfig) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.isEmpty(loadedConsumers, 'should not load disabled consumer'); - }); - }); - - it('should fail to load invalid pull consumer types (consumerType=unknowntype)', () => { - const exampleConfig = { - class: 'Telemetry', - My_Consumer: { - class: CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, - type: 'unknowntype', - systemPoller: 'My_Poller' - }, - My_Poller: { - class: CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME - } - }; - // config will not pass schema validation - // but this test allows catching if consumer module/dir is not configured properly - return configUtil.normalizeDeclaration(exampleConfig) - .then((normalized) => configWorker.emitAsync('change', normalized)) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.strictEqual( - Object.keys(loadedConsumers).indexOf('unknowntype'), - -1, - 'should not load invalid consumer type' - ); - }); - }); - - it('should unload unrequired pull consumers', () => { - const priorConfig = { - class: 'Telemetry', - My_Consumer: { - class: CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, - type: 'default', - systemPoller: 'My_Poller' - }, - My_Poller: { - class: CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME - } - }; - return configWorker.processDeclaration(priorConfig) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.deepStrictEqual(loadedConsumers[0].config.type, 'default', 'should load default consumer'); - return configWorker.emitAsync('change', { components: [], mappings: {} }); - }) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.isEmpty(loadedConsumers, 'should unload default consumer'); - }) - .catch((err) => Promise.reject(err)); - }); - - it('should not reload existing pull consumer when processing a new namespace declaration', () => { - let existingConsumer; - const priorConfig = { - class: 'Telemetry', - My_Consumer: { - class: CONFIG_CLASSES.PULL_CONSUMER_CLASS_NAME, - type: 'default', - systemPoller: 'My_Poller' - }, - My_Poller: { - class: CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME - } - }; - const namespaceConfig = { - class: 'Telemetry_Namespace', - My_Consumer: testUtil.deepCopy(priorConfig.My_Consumer), - My_Poller: testUtil.deepCopy(priorConfig.My_Poller) - }; - const moduleLoaderSpy = sinon.spy(moduleLoader, 'load'); - return configWorker.processDeclaration(priorConfig) - .then(() => { - const loadedConsumers = pullConsumers.getConsumers(); - assert.lengthOf(loadedConsumers, 1, 'should load default consumer'); - assert.isTrue(moduleLoaderSpy.calledOnce); - existingConsumer = loadedConsumers[0]; - }) - .then(() => configWorker.processNamespaceDeclaration(namespaceConfig, 'NewNamespace')) - .then(() => { - const loadedConsumerIds = pullConsumers.getConsumers().map((c) => c.id); - assert.lengthOf(loadedConsumerIds, 2, 'should load new consumer'); - assert.deepStrictEqual(loadedConsumerIds[0], existingConsumer.id); - assert.isTrue(moduleLoaderSpy.calledTwice); - }); - }); - }); - - describe('.getData()', () => { - let declaration; - let returnCtx; - - beforeEach(() => { - returnCtx = null; - sinon.stub(systemPoller, 'process').callsFake((pollerConfig) => { - if (returnCtx) { - return returnCtx(declaration, pollerConfig); - } - return Promise.resolve({ - data: { - mockedResponse: { - pollerName: pollerConfig.name, - systemName: pollerConfig.systemName - } - } - }); - }); - }); - - const runTestCase = (testConf) => testUtil.getCallableIt(testConf)(testConf.name, () => { - declaration = testConf.declaration; - if (typeof testConf.returnCtx !== 'undefined') { - returnCtx = testConf.returnCtx; - } - - return configWorker.processDeclaration(testUtil.deepCopy(declaration)) - .then(() => pullConsumers.getData(testConf.consumerName, testConf.namespace)) - .then((response) => { - assert.sameDeepMembers(response.data, testConf.expectedResponse); - }, (err) => { - if (testConf.errorRegExp) { - return assert.match(err, testConf.errorRegExp, 'should match error reg exp'); - } - return Promise.reject(err); - }); - }); - - describe('default (no namespace)', () => pullConsumersTestsData.getData.forEach((testConf) => runTestCase(testConf))); - - describe( - 'default (no namespace), lookup using f5telemetry_default name', - () => pullConsumersTestsData.getData.forEach((testConf) => { - testConf.namespace = 'f5telemetry_default'; - runTestCase(testConf); - }) - ); - - describe('with namespace only', () => { - const namespaceOnlyTestsData = testUtil.deepCopy(pullConsumersTestsData.getData); - namespaceOnlyTestsData.forEach((testConf) => { - testConf.declaration = { - class: 'Telemetry', - Namespace_Only: testConf.declaration - }; - testConf.declaration.Namespace_Only.class = 'Telemetry_Namespace'; - testConf.namespace = 'Namespace_Only'; - return runTestCase(testConf); - }); - }); - - describe('mix - default (no namespace) and with namespaces', () => { - const namespaceOnlyTestsData = testUtil.deepCopy(pullConsumersTestsData.getData); - namespaceOnlyTestsData.forEach((testConf) => { - testConf.declaration.Wanted_Namespace = testUtil.deepCopy(testConf.declaration); - testConf.declaration.Wanted_Namespace.class = 'Telemetry_Namespace'; - testConf.declaration.Extra_Namespace = { - class: 'Telemetry_Namespace', - A_System_Poller: { - class: 'Telemetry_System_Poller' - } - }; - testConf.namespace = 'Wanted_Namespace'; - return runTestCase(testConf); - }); - - const addtlTest = { - name: 'should return an error if consumer exists in namespace but no namespace provided', - declaration: { - class: 'Telemetry', - Some_System_Poller: { - class: 'Telemetry_System_Poller' - }, - Some_Consumer: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: 'Some_System_Poller' - }, - Wanted_Namespace: { - class: 'Telemetry_Namespace', - Wanted_Poller: { - class: 'Telemetry_System_Poller' - }, - Wanted_Consumer: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: 'Wanted_Poller' - } - } - }, - consumerName: 'Wanted_Consumer', - errorRegExp: /Pull Consumer with name 'Wanted_Consumer' doesn't exist/ - }; - return runTestCase(addtlTest); - }); - }); -}); diff --git a/test/unit/requestHandlers/baseHandlerTests.js b/test/unit/requestHandlers/baseHandlerTests.js deleted file mode 100644 index 80b9adee..00000000 --- a/test/unit/requestHandlers/baseHandlerTests.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const BaseRequestHandler = sourceCode('src/lib/requestHandlers/baseHandler'); - -moduleCache.remember(); - -describe('BaseRequestHandler', () => { - let restOpMock; - let requestHandler; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - restOpMock = new testUtil.MockRestOperation({ method: 'get' }); - restOpMock.uri = testUtil.parseURL('http://localhost:8100/mgmt/shared/telemetry/info'); - requestHandler = new BaseRequestHandler(restOpMock); - }); - - describe('.getBody()', () => { - it('should throw error', () => { - assert.throws( - () => requestHandler.getBody(), - 'Method "getBody" not implemented', - 'should throw error for method that not implemented' - ); - }); - }); - - describe('.getCode()', () => { - it('should throw error', () => { - assert.throws( - () => requestHandler.getCode(), - 'Method "getCode" not implemented', - 'should throw error for method that not implemented' - ); - }); - }); - - describe('.getHeaders()', () => { - it('should return empty HTTP headers', () => { - assert.deepStrictEqual(requestHandler.getHeaders(), {}, 'should return empty headers object by default'); - }); - - it('should return HTTP headers', () => { - restOpMock.setHeaders({ header: 'value' }); - assert.deepStrictEqual( - requestHandler.getHeaders(), - { header: 'value' }, - 'should return expected headers' - ); - }); - }); - - describe('.getMethod()', () => { - it('should return HTTP method', () => { - assert.deepStrictEqual(requestHandler.getMethod(), 'GET', 'should return method in upper case'); - }); - }); - - describe('.process()', () => { - it('should return self', () => requestHandler.process() - .then((inst) => { - assert.deepStrictEqual(inst, requestHandler, 'should return same instance'); - })); - }); - - describe('.getContentType()', () => { - it('should return undefined', () => { - assert.isUndefined(requestHandler.getContentType(), 'should return undefined by default'); - }); - }); -}); diff --git a/test/unit/requestHandlers/declareHandlerTests.js b/test/unit/requestHandlers/declareHandlerTests.js deleted file mode 100644 index 9de04709..00000000 --- a/test/unit/requestHandlers/declareHandlerTests.js +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const dummies = require('../shared/dummies'); -const sourceCode = require('../shared/sourceCode'); -const stubs = require('../shared/stubs'); -const testUtil = require('../shared/util'); - -const appInfo = sourceCode('src/lib/appInfo'); -const configWorker = sourceCode('src/lib/config'); -const DeclareHandler = sourceCode('src/lib/requestHandlers/declareHandler'); -const ErrorHandler = sourceCode('src/lib/requestHandlers/errorHandler'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); - -moduleCache.remember(); - -describe('DeclareHandler', () => { - let coreStub; - let requestHandler; - let uri; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - coreStub = stubs.default.coreStub(); - return persistentStorage.persistentStorage.load() - .then(() => configWorker.load()); - }); - - afterEach(() => { - requestHandler = null; - uri = null; - sinon.restore(); - }); - - function assertProcessResult(expected) { - return requestHandler.process() - .then((actual) => { - if (expected.code === 200) { - assert.ok(actual === requestHandler, 'should return a reference to original handler'); - } else { - assert.isTrue(actual instanceof ErrorHandler, 'should return a reference to error handler'); - } - - assert.strictEqual(actual.getCode(), expected.code, 'should return expected code'); - const actualBody = actual.getBody(); - if (expected.body.code) { - assert.strictEqual(actualBody.code, expected.body.code, 'should return expected body.code'); - assert.strictEqual(actualBody.message, expected.body.message, 'should return expected body.message'); - if (expected.body.error) { - assert.match(actualBody.error, new RegExp(expected.body.error), 'should return expected body.error'); - } - } else { - assert.deepStrictEqual(requestHandler.getBody(), expected.body, 'should return expected body'); - } - }); - } - - function assertMultiRequestResults(mockConfig, expectedResponses, params) { - const fetchResponseInfo = (handler) => ({ - code: handler.getCode(), - body: handler.getBody() - }); - - return Promise.all([ - testUtil.sleep(10).then(() => new DeclareHandler(getRestOperation('POST', mockConfig), params).process()), // should return 200 or 503 - testUtil.sleep(10).then(() => new DeclareHandler(getRestOperation('POST', mockConfig), params).process()), // should return 503 or 200 - testUtil.sleep(20).then(() => new DeclareHandler(getRestOperation('GET'), params).process()) // should return 200 - ]) - .then((handlers) => { - assert.deepStrictEqual(fetchResponseInfo(handlers[2]), expectedResponses.GET, 'should match expected response for GET'); - assert.includeDeepMembers(handlers.slice(0, 2).map(fetchResponseInfo), expectedResponses.POST, 'should match expected responses for POST requests'); - // lock should be released already - return new DeclareHandler(getRestOperation('POST', mockConfig), params).process(); - }) - .then((handler) => { - assert.deepStrictEqual(fetchResponseInfo(handler), expectedResponses.POST[0], 'should succeed after lock released'); - }); - } - - function getRestOperation(method, body) { - const restOpMock = new testUtil.MockRestOperation({ method: method.toUpperCase() }); - restOpMock.uri = testUtil.parseURL(uri); - restOpMock.body = body; - return restOpMock; - } - - describe('/declare', () => { - beforeEach(() => { - uri = 'http://localhost:8100/mgmt/shared/telemetry/declare'; - }); - - it('should get full raw config on GET request', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.base.decrypted({ - schemaVersion: appInfo.version - }) - } - }; - requestHandler = new DeclareHandler(getRestOperation('GET')); - return assertProcessResult(expected); - }); - - it('should return 200 on POST - valid declaration', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.base.decrypted({ - schemaVersion: appInfo.version - }) - } - }; - requestHandler = new DeclareHandler(getRestOperation( - 'POST', dummies.declaration.base.decrypted() - )); - return assertProcessResult(expected); - }); - - it('should return 422 on POST - invalid declaration', () => { - const expected = { - code: 422, - body: { - code: 422, - message: 'Unprocessable entity', - error: 'should be equal to one of the allowed values' - } - }; - requestHandler = new DeclareHandler(getRestOperation( - 'POST', dummies.declaration.base.decrypted({ class: 'Telemetry_Test' }) - )); - return assertProcessResult(expected); - }); - - it('should return 503 on attempt to POST declaration while previous one is still in process', () => { - const mockConfig = { config: 'validated' }; - sinon.stub(configWorker, 'processDeclaration').callsFake(() => testUtil.sleep(50).then(() => testUtil.deepCopy(mockConfig))); - sinon.stub(configWorker, 'getDeclaration').callsFake(() => testUtil.sleep(50).then(() => testUtil.deepCopy(mockConfig))); - - const expectedResponses = { - GET: { - code: 200, - body: { - message: 'success', - declaration: mockConfig - } - }, - POST: [ - { - code: 200, - body: { - message: 'success', - declaration: mockConfig - } - }, - { - code: 503, - body: { - code: 503, - message: 'Service Unavailable' - } - } - ] - }; - - return assertMultiRequestResults(mockConfig, expectedResponses); - }); - - it('should reject whe unknown error is caught', () => { - sinon.stub(configWorker, 'getDeclaration').rejects(new Error('expectedError')); - requestHandler = new DeclareHandler(getRestOperation('GET')); - return assert.isRejected(requestHandler.process(), 'expectedError'); - }); - - it('should compute request metadata', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.base.decrypted({ - schemaVersion: appInfo.version, - consumer: dummies.declaration.consumer.splunk.full.encrypted() - }) - } - }; - const restOp = getRestOperation( - 'POST', dummies.declaration.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }) - ); - restOp.setHeaders({ 'X-Forwarded-For': '192.168.0.1' }); - requestHandler = new DeclareHandler(restOp); - - return assertProcessResult(expected) - .then(() => { - const records = coreStub.configWorker.receivedSpy.args.map((args) => args[0]); - const recent = records[records.length - 1]; - assert.deepStrictEqual( - recent, - { - declaration: dummies.declaration.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }), - metadata: { - message: 'Incoming declaration via REST API', - originDeclaration: dummies.declaration.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }), - sourceIP: '192.168.0.1' - }, - transactionID: 'uuid2' - } - ); - }); - }); - }); - - describe('/namespace/:namespace/declare', () => { - beforeEach(() => { - uri = 'http://localhost:8100/mgmt/shared/telemetry/namespace/testNamespace/declare'; - return configWorker.processDeclaration(dummies.declaration.base.decrypted({ - testNamespace: dummies.declaration.namespace.base.decrypted(), - otherNamespace: dummies.declaration.namespace.base.decrypted() - })); - }); - - it('should get namespace-only raw config on GET request', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.namespace.base.decrypted() - } - }; - requestHandler = new DeclareHandler(getRestOperation('GET'), { namespace: 'testNamespace' }); - return assertProcessResult(expected); - }); - - it('should return 404 on GET - non-existent namespace', () => { - const expected = { - code: 404, - body: { - code: 404, - message: 'Namespace with name \'nonExistingNamespace\' doesn\'t exist' - } - }; - requestHandler = new DeclareHandler(getRestOperation('GET'), { namespace: 'nonExistingNamespace' }); - return assertProcessResult(expected); - }); - - it('should return 200 on POST - valid declaration', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.namespace.base.decrypted() - } - }; - requestHandler = new DeclareHandler( - getRestOperation( - 'POST', - dummies.declaration.namespace.base.decrypted() - ), - { namespace: 'testNamespace' } - ); - return assertProcessResult(expected); - }); - - it('should return 422 on POST - invalid declaration', () => { - const expected = { - code: 422, - body: { - code: 422, - message: 'Unprocessable entity', - error: /"schemaPath":"#\/properties\/class\/enum","params":{"allowedValues":\["Telemetry_Namespace"\]/ - } - }; - requestHandler = new DeclareHandler( - getRestOperation( - 'POST', - dummies.declaration.namespace.base.decrypted({ class: 'Telemetry' }) - ), - { namespace: 'testNamespace' } - ); - return assertProcessResult(expected); - }); - - it('should return 503 on attempt to POST declaration while previous one is still in process', () => { - const namespaceConfig = dummies.declaration.namespace.base.decrypted(); - sinon.stub(configWorker, 'processDeclaration').callsFake(function () { - return testUtil.sleep(50) - .then(() => { - configWorker.processDeclaration.restore(); - return configWorker.processDeclaration.apply(configWorker, arguments); - }); - }); - - const expectedResponses = { - GET: { - code: 200, - body: { - message: 'success', - declaration: namespaceConfig - } - }, - POST: [ - { - code: 200, - body: { - message: 'success', - declaration: namespaceConfig - } - }, - { - code: 503, - body: { - code: 503, - message: 'Service Unavailable' - } - } - ] - }; - - return assertMultiRequestResults(namespaceConfig, expectedResponses, { namespace: 'testNamespace' }); - }); - - it('should reject when unknown error is caught', () => { - sinon.stub(configWorker, 'getDeclaration').rejects(new Error('expectedError')); - requestHandler = new DeclareHandler( - getRestOperation( - 'POST', - dummies.declaration.namespace.base.decrypted() - ), - { namespace: 'testNamespace' } - ); - return assert.isRejected(requestHandler.process(), 'expectedError'); - }); - - it('should compute request metadata', () => { - const expected = { - code: 200, - body: { - message: 'success', - declaration: dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.full.encrypted() - }) - } - }; - requestHandler = new DeclareHandler( - getRestOperation( - 'POST', - dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }) - ), - { namespace: 'testNamespace' } - ); - return assertProcessResult(expected) - .then(() => { - const records = coreStub.configWorker.receivedSpy.args.map((args) => args[0]); - const recent = records[records.length - 1]; - assert.deepStrictEqual( - recent, - { - declaration: dummies.declaration.base.decrypted({ - schemaVersion: appInfo.version, - otherNamespace: dummies.declaration.namespace.base.decrypted({}), - testNamespace: dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }) - }), - metadata: { - message: 'Incoming declaration via REST API', - originDeclaration: dummies.declaration.namespace.base.decrypted({ - consumer: dummies.declaration.consumer.splunk.minimal.decrypted() - }), - namespace: 'testNamespace', - sourceIP: undefined // not specified in headers - }, - transactionID: 'uuid5' - } - ); - }); - }); - }); -}); diff --git a/test/unit/requestHandlers/errorHandlerTests.js b/test/unit/requestHandlers/errorHandlerTests.js deleted file mode 100644 index 7f530a31..00000000 --- a/test/unit/requestHandlers/errorHandlerTests.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const ErrorHandler = sourceCode('src/lib/requestHandlers/errorHandler'); -const errors = sourceCode('src/lib/errors'); -const httpErrors = sourceCode('src/lib/requestHandlers/httpErrors'); - -moduleCache.remember(); - -describe('ErrorHandler', () => { - before(() => { - moduleCache.restore(); - }); - - let errorHandler; - - const testData = [ - { - name: 'Bad URL', - error: new httpErrors.BadURLError('/a/b/c/d'), - expected: { - code: 400, - body: 'Bad URL: /a/b/c/d' - } - }, - { - name: 'Internal Server Error', - error: new httpErrors.InternalServerError('beep-badoo-bop'), - expected: { - code: 500, - body: { - code: 500, - message: 'Internal Server Error' - } - } - }, - { - name: 'Method Not Allowed', - error: new httpErrors.MethodNotAllowedError(['PATCH', 'HEAD']), - expected: { - code: 405, - body: { - code: 405, - message: 'Method Not Allowed', - allow: ['PATCH', 'HEAD'] - } - } - }, - { - name: 'Service Unavailable', - error: new httpErrors.ServiceUnavailableError(), - expected: { - code: 503, - body: { - code: 503, - message: 'Service Unavailable' - } - } - }, - { - name: 'Unsupported Media Type', - error: new httpErrors.UnsupportedMediaTypeError(), - expected: { - code: 415, - body: { - code: 415, - message: 'Unsupported Media Type', - accept: ['application/json'] - } - } - }, - { - name: 'Config Lookup Error', - error: new errors.ObjectNotFoundInConfigError('Unable to find object'), - expected: { - code: 404, - body: { - code: 404, - message: 'Unable to find object' - } - } - }, - { - name: 'Validation Error', - error: new errors.ValidationError('Does not conform to schema'), - expected: { - code: 422, - body: { - code: 422, - message: 'Unprocessable entity', - error: 'Does not conform to schema' - } - } - } - ]; - - function assertProcessResult(expected) { - assert.strictEqual(errorHandler.getCode(), expected.code, 'should return expected code'); - assert.deepStrictEqual(errorHandler.getBody(), expected.body, 'should match expected body'); - return errorHandler.process() - .then((handler) => { - assert.ok(handler === errorHandler, 'should return a reference to original handler'); - }); - } - - testData.forEach((testConf) => { - testUtil.getCallableIt(testConf)(`should handle error - ${testConf.name}`, () => { - errorHandler = new ErrorHandler(testConf.error); - assertProcessResult(testConf.expected); - }); - }); - - it('should reject if error is of unknown type', () => { - errorHandler = new ErrorHandler(new Error('i am a stealthy error')); - return assert.isRejected(errorHandler.process()) - .then((result) => { - assert.strictEqual(result.message, 'i am a stealthy error'); - }); - }); -}); diff --git a/test/unit/requestHandlers/eventListenerHandlerTests.js b/test/unit/requestHandlers/eventListenerHandlerTests.js deleted file mode 100644 index 8e8d99a9..00000000 --- a/test/unit/requestHandlers/eventListenerHandlerTests.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const ErrorHandler = sourceCode('src/lib/requestHandlers/errorHandler'); -const errors = sourceCode('src/lib/errors'); -const EventListenerHandler = sourceCode('src/lib/requestHandlers/eventListenerHandler'); -const EventListenerPublisher = sourceCode('src/lib/eventListener/dataPublisher'); -const requestRouter = sourceCode('src/lib/requestHandlers/router'); - -moduleCache.remember(); - -describe('EventListenerHandler', () => { - const buildRequestHandler = (opts) => { - opts = opts || {}; - const restOpMock = new testUtil.MockRestOperation({ method: 'POST' }); - restOpMock.uri = opts.uri || 'http://localhost:8100/mgmt/shared/telemetry/eventListener/My_Listener'; - restOpMock.body = opts.body || { data: 'testData' }; - return new EventListenerHandler(restOpMock, opts.handlerOpts || { eventListener: 'My_Listener' }); - }; - - before(() => { - moduleCache.restore(); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should return 200 on POST (no namespace)', () => { - const requestHandler = buildRequestHandler(); - const sendDataStub = sinon.stub(EventListenerPublisher, 'sendDataToListener').resolves(); - - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual( - requestHandler.getBody(), - { data: { data: 'testData' }, message: 'success' }, - 'should return expected body' - ); - assert.strictEqual(sendDataStub.callCount, 1, 'should be called once'); - assert.deepStrictEqual( - sendDataStub.firstCall.args, - [{ data: 'testData' }, 'My_Listener', { namespace: undefined }], - 'should be called once' - ); - }); - }); - - it('should return 200 on POST (in namespace)', () => { - const requestHandler = buildRequestHandler({ - uri: 'http://localhost:8100/mgmt/shared/telemetry/namespace/My_Namespace/eventListener/My_Listener', - body: { data: 'testData' }, - handlerOpts: { namespace: 'My_Namespace', eventListener: 'My_Listener' } - }); - const sendDataStub = sinon.stub(EventListenerPublisher, 'sendDataToListener').resolves(); - - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual( - requestHandler.getBody(), - { data: { data: 'testData' }, message: 'success' }, - 'should return expected body' - ); - assert.strictEqual(sendDataStub.callCount, 1, 'should be called once'); - assert.deepStrictEqual( - sendDataStub.firstCall.args, - [{ data: 'testData' }, 'My_Listener', { namespace: 'My_Namespace' }], - 'should be called once' - ); - }); - }); - - it('should return 404 if Event Listener is not found', () => { - const requestHandler = buildRequestHandler(); - sinon.stub(EventListenerPublisher, 'sendDataToListener').rejects(new errors.ConfigLookupError('listener not found')); - return requestHandler.process() - .then((handler) => { - assert.isTrue(handler instanceof ErrorHandler, 'should return a reference to error handler'); - assert.strictEqual(handler.getCode(), 404, 'should return expected code'); - assert.deepStrictEqual(handler.getBody(), { - code: 404, - message: 'listener not found' - }, 'should return expected body'); - }); - }); - - it('should register endpoints when debug=true', () => { - const spy = sinon.spy(); - requestRouter.on('register', spy); - requestRouter.removeAllHandlers(); - requestRouter.registerAllHandlers(true); - const routePaths = spy.firstCall.args[0].router.routes.map((r) => r.path); - - assert.isTrue(routePaths.indexOf('/eventListener/:eventListener') > -1); - assert.isTrue(routePaths.indexOf('/namespace/:namespace/eventListener/:eventListener') > -1); - }); - - it('should not register endpoints when debug=false', () => { - const spy = sinon.spy(); - requestRouter.on('register', spy); - requestRouter.removeAllHandlers(); - requestRouter.registerAllHandlers(); - const routePaths = spy.firstCall.args[0].router.routes.map((r) => r.path); - - assert.strictEqual(routePaths.indexOf('/eventListener/:eventListener'), -1); - assert.strictEqual(routePaths.indexOf('/namespace/:namespace/eventListener/:eventListener'), -1); - }); - - it('should reject when caught unknown error', () => { - const requestHandler = buildRequestHandler(); - sinon.stub(EventListenerPublisher, 'sendDataToListener').rejects(new Error('unexpectedError')); - return assert.isRejected(requestHandler.process(), 'unexpectedError'); - }); -}); diff --git a/test/unit/requestHandlers/ihealthPollerHandlerTests.js b/test/unit/requestHandlers/ihealthPollerHandlerTests.js deleted file mode 100644 index 17310e35..00000000 --- a/test/unit/requestHandlers/ihealthPollerHandlerTests.js +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const dummies = require('../shared/dummies'); -const sourceCode = require('../shared/sourceCode'); -const stubs = require('../shared/stubs'); -const testUtil = require('../shared/util'); - -const configWorker = sourceCode('src/lib/config'); -const ihealth = sourceCode('src/lib/ihealth'); -const IHealthPoller = sourceCode('src/lib/ihealthPoller'); -// eslint-disable-next-line no-unused-vars -const IHealthPollerHandler = sourceCode('src/lib/requestHandlers/ihealthPollerHandler'); -const ihealthUtil = sourceCode('src/lib/utils/ihealth'); -const router = sourceCode('src/lib/requestHandlers/router'); - -moduleCache.remember(); - -describe('IHealthPollerHandler', () => { - let restOpMock; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - const coreStub = stubs.default.coreStub(); - coreStub.utilMisc.generateUuid.numbersOnly = false; - - const ihealthStub = stubs.iHealthPoller({ - ihealthUtil - }); - // slow down polling process - ihealthStub.ihealthUtil.QkviewManager.process.rejects(new Error('expected error')); - - const declaration = dummies.declaration.base.decrypted(); - declaration.controls = dummies.declaration.controls.full.decrypted(); - declaration.System = dummies.declaration.system.full.decrypted({ - host: '192.168.0.1', - username: 'test_user_1', - passphrase: { cipherText: 'test_passphrase_1' } - }); - declaration.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_2', - passphrase: { cipherText: 'test_passphrase_2' }, - proxy: { - host: '192.168.100.1', - username: 'test_user_3', - passphrase: { cipherText: 'test_passphrase_3' } - } - }); - declaration.Namespace = dummies.declaration.namespace.base.decrypted(); - declaration.Namespace.System = dummies.declaration.system.full.decrypted({ - host: '192.168.0.3', - username: 'test_user_7', - passphrase: { cipherText: 'test_passphrase_7' } - }); - declaration.Namespace.System.iHealthPoller = dummies.declaration.ihealthPoller.inlineFull.decrypted({ - username: 'test_user_8', - passphrase: { cipherText: 'test_passphrase_8' }, - proxy: { - host: '192.168.100.3', - username: 'test_user_9', - passphrase: { cipherText: 'test_passphrase_9' } - } - }); - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/System'); - - return configWorker.processDeclaration(declaration) - .then(() => { - assert.lengthOf(IHealthPoller.getAll({ includeDemo: true }), 2, 'should have 2 running pollers'); - }); - }); - - afterEach(() => configWorker.processDeclaration({ class: 'Telemetry' }) - .then(() => { - sinon.restore(); - assert.isEmpty(IHealthPoller.getAll({ includeDemo: true }), 'should have 0 running pollers'); - })); - - describe('/ihealthpoller', () => { - it('should return 200 on GET request to retrieve current state (all pollers)', () => { - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 200, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 200, 'should return expected code'); - assert.sameDeepMembers( - restOpMock.getBody().message.map((s) => s.name), - [ - 'f5telemetry_default::System::iHealthPoller_1', - 'Namespace::System::iHealthPoller_1' - ], - 'should return expected body' - ); - }); - }); - - it('should return 500 on GET request to retrieve current state and unexpected error thrown', () => { - sinon.stub(ihealth, 'getCurrentState').throws(new Error('expected error')); - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 500, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody(), { - code: 500, - message: 'Internal Server Error' - }, 'should return expected body'); - }); - }); - - it('should return 404 when unable to make config lookup', () => { - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/NonExistingSystem'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 404, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody(), { - code: 404, - message: 'System or iHealth Poller declaration not found' - }, 'should return expected body'); - }); - }); - - it('should return 500 on GET request to start poller and unexpected error thrown', () => { - sinon.stub(ihealth, 'startPoller').rejects(new Error('expectedError')); - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/System'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 500, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody(), { - code: 500, - message: 'Internal Server Error' - }, 'should return expected body'); - }); - }); - - it('should return 201 on GET request to start demo poller', () => { - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/System'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 201, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 201, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - }); - }); - - it('should return 202 on GET request to start demo poller that running already', () => { - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/System'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 201, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 201, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.parseAndSetURI('http://localhost:8100/ihealthpoller/System'); - return router.processRestOperation(restOpMock); - }) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 202, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 202, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'f5telemetry_default::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - }); - }); - }); - - describe('/namespace/:namespace/ihealthpoller', () => { - it('should return 200 on GET request to retrieve current state', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/Namespace/ihealthpoller'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 200, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 200, 'should return expected code'); - assert.sameDeepMembers( - restOpMock.getBody().message.map((s) => s.name), - [ - 'Namespace::System::iHealthPoller_1' - ], - 'should return expected body' - ); - }); - }); - - it('should return 200 on GET request to retrieve current state (non-existing namespace)', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/NonExistingNamespace/ihealthpoller'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 200, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 200, 'should return expected code'); - assert.isEmpty(restOpMock.getBody().message.map((s) => s.name), 'should return expected body'); - }); - }); - - it('should return 404 when unable to make config lookup (non-existing namespace)', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/NonExistingNamespace/ihealthpoller/NonExistingSystem'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 404, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody(), { - code: 404, - message: 'System or iHealth Poller declaration not found' - }, 'should return expected body'); - }); - }); - - it('should return 404 when unable to make config lookup (non-existing system in namespace)', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/Namespace/ihealthpoller/NonExistingSystem'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 404, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody(), { - code: 404, - message: 'System or iHealth Poller declaration not found' - }, 'should return expected body'); - }); - }); - - it('should return 201 on GET request to start demo poller', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/Namespace/ihealthpoller/System'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 201, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 201, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'Namespace::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - }); - }); - - it('should return 202 on GET request to start demo poller that running already', () => { - restOpMock.parseAndSetURI('http://localhost:8100/namespace/Namespace/ihealthpoller/System'); - return router.processRestOperation(restOpMock) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 201, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 201, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'Namespace::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.parseAndSetURI('http://localhost:8100/namespace/Namespace/ihealthpoller/System'); - return router.processRestOperation(restOpMock); - }) - .then(() => { - assert.strictEqual(restOpMock.getStatusCode(), 202, 'should return expected code'); - assert.strictEqual(restOpMock.getBody().code, 202, 'should return expected code'); - assert.deepStrictEqual(restOpMock.getBody().state.name, 'Namespace::System::iHealthPoller_1 (DEMO)', 'should return expected body'); - }); - }); - }); -}); diff --git a/test/unit/requestHandlers/infoHandlerTests.js b/test/unit/requestHandlers/infoHandlerTests.js deleted file mode 100644 index f156c655..00000000 --- a/test/unit/requestHandlers/infoHandlerTests.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const InfoHandler = sourceCode('src/lib/requestHandlers/infoHandler'); -const packageJson = sourceCode('package.json'); -const schemaJson = sourceCode('src/schema/latest/base_schema.json'); - -moduleCache.remember(); - -describe('InfoHandler', () => { - let restOpMock; - let requestHandler; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.uri = testUtil.parseURL('http://localhost:8100/mgmt/shared/telemetry/info'); - requestHandler = new InfoHandler(restOpMock); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should return info data on GET request (real data)', () => requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - - const pkgInfo = packageJson.version.split('-'); - const schemaInfo = schemaJson.properties.schemaVersion.enum; - assert.deepStrictEqual(requestHandler.getBody(), { - branch: 'gitbranch', - buildID: 'githash', - buildTimestamp: 'buildtimestamp', - fullVersion: packageJson.version, - nodeVersion: process.version, - release: pkgInfo[1], - schemaCurrent: schemaInfo[0], - schemaMinimum: schemaInfo[schemaInfo.length - 1], - version: pkgInfo[0] - }, 'should return expected body'); - })); -}); diff --git a/test/unit/requestHandlers/pullConsumerHandlerTests.js b/test/unit/requestHandlers/pullConsumerHandlerTests.js deleted file mode 100644 index 3cf9ee5b..00000000 --- a/test/unit/requestHandlers/pullConsumerHandlerTests.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const errors = sourceCode('src/lib/errors'); -const pullConsumers = sourceCode('src/lib/pullConsumers'); -const PullConsumerHandler = sourceCode('src/lib/requestHandlers/pullConsumerHandler'); -const ErrorHandler = sourceCode('src/lib/requestHandlers/errorHandler'); - -moduleCache.remember(); - -describe('PullConsumerHandler', () => { - let restOpMock; - let requestHandler; - - before(() => { - moduleCache.restore(); - }); - - afterEach(() => { - sinon.restore(); - }); - - const assertUnknownError = () => { - sinon.stub(pullConsumers, 'getData').rejects(new Error('expectedError')); - return assert.isRejected(requestHandler.process(), 'expectedError'); - }; - - const assertConfigLookupError = () => { - sinon.stub(pullConsumers, 'getData').rejects(new errors.ConfigLookupError('expectedError')); - return requestHandler.process() - .then((handler) => { - assert.isTrue(handler instanceof ErrorHandler, 'should return a reference to error handler'); - assert.strictEqual(handler.getCode(), 404, 'should return expected code'); - assert.deepStrictEqual(handler.getBody(), { - code: 404, - message: 'expectedError' - }, 'should return expected body'); - }); - }; - - const shouldReportContentType = (expectedData) => { - const expectedContentType = 'text/plain; version=0.0.4'; - sinon.stub(pullConsumers, 'getData').callsFake( - () => Promise.resolve(testUtil.deepCopy({ data: expectedData, contentType: expectedContentType })) - ); - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual(requestHandler.getBody(), expectedData, 'should return expected body'); - assert.strictEqual(requestHandler.getContentType(), expectedContentType, 'should return undefined by default'); - }); - }; - - describe('/pullconsumer/:consumer', () => { - beforeEach(() => { - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.uri = testUtil.parseURL('http://localhost:8100/mgmt/shared/telemetry/pullconsumer/consumer'); - requestHandler = new PullConsumerHandler(restOpMock, { consumer: 'consumer' }); - }); - - it('should return 200 on GET request', () => { - let consumerNameFromRequest; - const expectedData = { pullconsumer: 'pullconsumer' }; - sinon.stub(pullConsumers, 'getData').callsFake((consumerName) => { - consumerNameFromRequest = consumerName; - return Promise.resolve({ data: testUtil.deepCopy(expectedData) }); - }); - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual(requestHandler.getBody(), expectedData, 'should return expected body'); - assert.isUndefined(requestHandler.getContentType(), 'should return undefined by default'); - assert.strictEqual(consumerNameFromRequest, 'consumer', 'should match name from request'); - }); - }); - - it('should return contentType when specified', () => { - const expectedData = { pullconsumer: 'pullconsumer' }; - return shouldReportContentType(expectedData); - }); - - it('should return 404 when unable to make config lookup', assertConfigLookupError); - - it('should reject when caught unknown error', assertUnknownError); - }); - - describe('/namespace/:namespace/pullconsumer/:consumer', () => { - beforeEach(() => { - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.uri = testUtil.parseURL('http://localhost:8100/mgmt/shared/telemetry/namespace/somenamespace/pullconsumer/consumer'); - requestHandler = new PullConsumerHandler(restOpMock, { consumer: 'consumer', namespace: 'somenamespace' }); - }); - - it('should return 200 on GET request (with namespace in path)', () => { - let consumerNameFromRequest; - let namespaceFromRequest; - const expectedData = { name: 'consumer', namespace: 'somenamespace' }; - sinon.stub(pullConsumers, 'getData').callsFake((consumerName, namespace) => { - consumerNameFromRequest = consumerName; - namespaceFromRequest = namespace; - return Promise.resolve({ data: testUtil.deepCopy(expectedData) }); - }); - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual(requestHandler.getBody(), expectedData, 'should return expected body'); - assert.isUndefined(requestHandler.getContentType(), 'should return undefined by default'); - assert.strictEqual(consumerNameFromRequest, 'consumer', 'should match consumer name from request'); - assert.strictEqual(namespaceFromRequest, 'somenamespace', 'should match namespace from request'); - }); - }); - - it('should return contentType when specified (with namespace in path)', () => { - const expectedData = { name: 'consumer', namespace: 'somenamespace' }; - return shouldReportContentType(expectedData); - }); - - it('should return 404 when unable to make config lookup', assertConfigLookupError); - - it('should reject when caught unknown error', assertUnknownError); - }); -}); diff --git a/test/unit/requestHandlers/routerTests.js b/test/unit/requestHandlers/routerTests.js deleted file mode 100644 index 8f482fba..00000000 --- a/test/unit/requestHandlers/routerTests.js +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const stubs = require('../shared/stubs'); -const testUtil = require('../shared/util'); - -const BaseRequestHandler = sourceCode('src/lib/requestHandlers/baseHandler'); -const configWorker = sourceCode('src/lib/config'); -const httpErrors = sourceCode('src/lib/requestHandlers/httpErrors'); -const requestRouter = sourceCode('src/lib/requestHandlers/router'); - -moduleCache.remember(); - -class CustomRequestHandler extends BaseRequestHandler { - constructor(restOperation, params) { - super(restOperation, params); - this.code = 999; - this.body = 'body'; - } - - getCode() { - return this.code; - } - - setCode(code) { - this.code = code; - } - - getBody() { - return this.body; - } - - setBody(body) { - this.body = body; - } - - process() { - return Promise.resolve(this); - } -} - -describe('Requests Router', () => { - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - stubs.default.coreStub({ - configWorker: true, - persistentStorage: true, - teemReporter: true - }); - requestRouter.removeAllHandlers(); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should process rest operation', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - }); - }); - - it('should process rest operation with custom contentType', () => { - class CustomContentTypeHandler extends CustomRequestHandler { - getContentType() { - return 'Custom Content Type'; - } - } - requestRouter.register('GET', '/test', CustomContentTypeHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.strictEqual(restOp.contentType, 'Custom Content Type', 'should set contentType'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - }); - }); - - it('should pass matched params to handler', () => { - requestRouter.register('GET', '/test/:param1/:param2', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'getBody').callsFake(function () { - return this.params; - }); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test/system/poller'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, { - param1: 'system', - param2: 'poller' - }, 'should set body'); - }); - }); - - it('should pass matched params to handler (optional param not set)', () => { - requestRouter.register('GET', '/test/:param1/:param2?', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'getBody').callsFake(function () { - return this.params; - }); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test/system'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, { - param1: 'system' - }, 'should set body'); - }); - }); - - it('should pass matched params to handler (optional param set)', () => { - requestRouter.register('GET', '/test/:param1/:param2?', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'getBody').callsFake(function () { - return this.params; - }); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test/system/poller'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, { - param1: 'system', - param2: 'poller' - }, 'should set body'); - }); - }); - - it('should return bad url error when URI does not match', () => { - requestRouter.register('GET', '/test/:param1/:param2', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'getBody').callsFake(function () { - return this.params; - }); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test/system'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 400, 'should set code to 400'); - assert.deepStrictEqual(restOp.body, 'Bad URL: /test/system', 'should set body'); - }); - }); - - it('should process rest operation (with URI prefix)', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/prefix/test'); - - return requestRouter.processRestOperation(restOp, 'prefix') - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - }); - }); - - it('should process rest operation (with URI prefix, leading /)', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/prefix/test'); - - return requestRouter.processRestOperation(restOp, '/prefix') - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - }); - }); - - it('should process rest operation when URI prefix does not match', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp, '/anotherPrefix') - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - }); - }); - - it('should register handler for multiple HTTP methods', () => { - requestRouter.register(['GET', 'POST'], '/test', CustomRequestHandler); - const getRestOp = new testUtil.MockRestOperation({ method: 'GET' }); - getRestOp.uri = testUtil.parseURL('http://localhost/test'); - const postRestOp = new testUtil.MockRestOperation({ method: 'POST' }); - postRestOp.uri = testUtil.parseURL('http://localhost/test'); - - const promises = [ - requestRouter.processRestOperation(getRestOp) - .then(() => { - assert.strictEqual(getRestOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(getRestOp.body, 'body', 'should set body'); - }), - requestRouter.processRestOperation(postRestOp) - .then(() => { - assert.strictEqual(postRestOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(postRestOp.body, 'body', 'should set body'); - }) - ]; - return Promise.all(promises); - }); - - it('should return unsupported media type error', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'GET', body: 'requestBody' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 415, 'should set code to 415'); - assert.deepStrictEqual(restOp.body, { - code: 415, - message: 'Unsupported Media Type', - accept: ['application/json'] - }, 'should set expected body'); - }); - }); - - it('should return bad url error', () => { - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 400, 'should set code to 400'); - assert.deepStrictEqual(restOp.body, 'Bad URL: /test', 'should set expected body'); - }); - }); - - it('should return method not allowed error', () => { - requestRouter.register(['GET', 'POST'], '/test', CustomRequestHandler); - const restOp = new testUtil.MockRestOperation({ method: 'DELETE' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 405, 'should set code to 405'); - assert.deepStrictEqual(restOp.body, { - code: 405, - message: 'Method Not Allowed', - allow: ['GET', 'POST'] - }, 'should set expected body'); - }); - }); - - it('should return server internal error when error thrown in sync part of the code', () => { - requestRouter.register(['GET', 'POST'], '/test', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'process').throws(new Error('expectedError')); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 500, 'should set code to 500'); - assert.deepStrictEqual(restOp.body, { - code: 500, - message: 'Internal Server Error' - }, 'should set expected body'); - }); - }); - - it('should return server internal error when error thrown in async part of the code', () => { - requestRouter.register(['GET', 'POST'], '/test', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'process').rejects(new Error('expectedError')); - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 500, 'should set code to 500'); - assert.deepStrictEqual(restOp.body, { - code: 500, - message: 'Internal Server Error' - }, 'should set expected body'); - }); - }); - - it('should return hardcoded \'internal server error\' when error handler fails', () => { - requestRouter.register(['GET', 'POST'], '/test', CustomRequestHandler); - sinon.stub(CustomRequestHandler.prototype, 'process').rejects(new Error('expectedError')); - sinon.stub(httpErrors.InternalServerError.prototype, 'getBody').throws(new Error('ISE_Error')); - - const restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/test'); - - return requestRouter.processRestOperation(restOp) - .then(() => { - assert.strictEqual(restOp.statusCode, 500, 'should set code to 500'); - assert.deepStrictEqual(restOp.body, 'Internal Server Error', 'should set expected body'); - }); - }); - - it('should unregister all handlers', () => { - requestRouter.register('GET', '/test', CustomRequestHandler); - let restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/prefix/test'); - - return requestRouter.processRestOperation(restOp, 'prefix') - .then(() => { - assert.strictEqual(restOp.statusCode, 999, 'should set code to 999'); - assert.deepStrictEqual(restOp.body, 'body', 'should set body'); - requestRouter.removeAllHandlers(); - - restOp = new testUtil.MockRestOperation({ method: 'GET' }); - restOp.uri = testUtil.parseURL('http://localhost/prefix/test'); - return requestRouter.processRestOperation(restOp, 'prefix'); - }) - .then(() => { - assert.strictEqual(restOp.statusCode, 400, 'should set code to 400'); - assert.deepStrictEqual(restOp.body, 'Bad URL: /prefix/test', 'should set expected body'); - }); - }); - - it('should emit register event', () => { - const spy = sinon.spy(); - requestRouter.on('register', spy); - requestRouter.registerAllHandlers(); - requestRouter.registerAllHandlers(); - assert.strictEqual(spy.callCount, 2, 'should call registered listener'); - }); - - it('should register handlers on config change event', () => { - const spy = sinon.spy(); - requestRouter.on('register', spy); - return configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - debug: true - } - }) - .then(() => { - assert.ok(spy.args[0][0] === requestRouter, 'should pass router instance'); - assert.strictEqual(spy.args[0][1], true, 'should pass debug state'); - return configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls' - } - }); - }) - .then(() => { - assert.ok(spy.args[1][0] === requestRouter, 'should pass router instance'); - assert.strictEqual(spy.args[1][1], false, 'should pass debug state'); - }); - }); -}); diff --git a/test/unit/requestHandlers/systemPollerHandlerTests.js b/test/unit/requestHandlers/systemPollerHandlerTests.js deleted file mode 100644 index dd144960..00000000 --- a/test/unit/requestHandlers/systemPollerHandlerTests.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const errors = sourceCode('src/lib/errors'); -const systemPoller = sourceCode('src/lib/systemPoller'); -const SystemPollerHandler = sourceCode('src/lib/requestHandlers/systemPollerHandler'); -const ErrorHandler = sourceCode('src/lib/requestHandlers/errorHandler'); - -moduleCache.remember(); - -describe('SystemPollerHandler', () => { - let restOpMock; - let requestHandler; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - restOpMock = new testUtil.MockRestOperation({ method: 'GET' }); - restOpMock.uri = testUtil.parseURL('http://localhost:8100/mgmt/shared/telemetry/systempoller/system/poller'); - requestHandler = new SystemPollerHandler(restOpMock, { system: 'system', poller: 'poller' }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should return 200 on GET request', () => { - let systemNameFromRequest; - let pollerNameFromRequest; - let includeDisabledVal; - - sinon.stub(systemPoller, 'getPollersConfig').callsFake((systemName, options) => { - systemNameFromRequest = systemName; - pollerNameFromRequest = options.pollerName; - includeDisabledVal = options.includeDisabled; - return Promise.resolve({}); - }); - sinon.stub(systemPoller, 'fetchPollersData').resolves([{ data: 'expectedData' }]); - - return requestHandler.process() - .then((handler) => { - assert.ok(handler === requestHandler, 'should return a reference to original handler'); - assert.strictEqual(requestHandler.getCode(), 200, 'should return expected code'); - assert.deepStrictEqual(requestHandler.getBody(), ['expectedData'], 'should return expected body'); - assert.strictEqual(systemNameFromRequest, 'system', 'should match system name from request'); - assert.strictEqual(pollerNameFromRequest, 'poller', 'should match poller name from request'); - assert.strictEqual(includeDisabledVal, true, 'should include disabled configs too'); - }); - }); - - it('should return 404 when unable to make config lookup', () => { - sinon.stub(systemPoller, 'getPollersConfig').rejects(new errors.ConfigLookupError('expectedError')); - return requestHandler.process() - .then((handler) => { - assert.isTrue(handler instanceof ErrorHandler, 'should return a reference to error handler'); - assert.strictEqual(handler.getCode(), 404, 'should return expected code'); - assert.deepStrictEqual(handler.getBody(), { - code: 404, - message: 'expectedError' - }, 'should return expected body'); - }); - }); - - it('should reject when caught unknown error', () => { - sinon.stub(systemPoller, 'getPollersConfig').rejects(new Error('expectedError')); - return assert.isRejected(requestHandler.process(), 'expectedError'); - }); -}); diff --git a/test/unit/resourceMonitor/memoryMonitorTests.js b/test/unit/resourceMonitor/memoryMonitorTests.js index b1488872..304b9347 100644 --- a/test/unit/resourceMonitor/memoryMonitorTests.js +++ b/test/unit/resourceMonitor/memoryMonitorTests.js @@ -98,12 +98,14 @@ describe('Resource Monitor / Memory Monitor', () => { describe('service activity', () => { let clock; - let fsUtil; - beforeEach(() => { - fsUtil = { - readFileSync: () => PROC_MEM_INFO_OUTPUT - }; + function updateProcMemInfo(data) { + coreStub.utilMisc.fs.promise.writeFileSync('/proc/meminfo', data); + } + + beforeEach(async () => { + await coreStub.utilMisc.fs.promise.mkdir('/proc'); + updateProcMemInfo(PROC_MEM_INFO_OUTPUT); Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { external: 10 * 1024 * 1024, @@ -116,9 +118,7 @@ describe('Resource Monitor / Memory Monitor', () => { }); it('should provide memory usage stats (simple check, default intervals - 1.5 sec)', () => { - memMon = new MemoryMonitor(callback, { - fs: fsUtil - }); + memMon = new MemoryMonitor(callback); return Promise.all([ memMon.start(), clock.clockForward(1000, { promisify: true, repeat: 2, delay: 50 }) @@ -154,7 +154,6 @@ describe('Resource Monitor / Memory Monitor', () => { it('should provide memory usage stats (non default intervals)', () => { memMon = new MemoryMonitor(callback, { freeMemoryLimit: 10, - fs: fsUtil, intervals: [ { usage: 50, interval: 0.5 }, { usage: 90, interval: 0.3 }, @@ -191,8 +190,7 @@ describe('Resource Monitor / Memory Monitor', () => { utilizationPercent: 6.800000000000001 } }); - - fsUtil.readFileSync = () => 'MemAvailable: 9000 kB'; + updateProcMemInfo('MemAvailable: 9000 kB'); }) .then(() => clock.clockForward(300, { promisify: true, repeat: 2, delay: 10 })) .then(() => { @@ -220,7 +218,7 @@ describe('Resource Monitor / Memory Monitor', () => { } }); - fsUtil.readFileSync = () => PROC_MEM_INFO_OUTPUT; + updateProcMemInfo(PROC_MEM_INFO_OUTPUT); }) .then(() => clock.clockForward(600, { promisify: true, repeat: 2, delay: 10 })) .then(() => { @@ -251,7 +249,7 @@ describe('Resource Monitor / Memory Monitor', () => { .then(() => clock.clockForward(75, { promisify: true, repeat: 2, delay: 10 })) .then(() => { assert.lengthOf(results, 3); - fsUtil.readFileSync = () => 'MemFree: 9000 kB'; + updateProcMemInfo('MemFree: 9000 kB'); }) .then(() => clock.clockForward(300, { promisify: true, repeat: 2, delay: 10 })) .then(() => { @@ -311,7 +309,7 @@ describe('Resource Monitor / Memory Monitor', () => { utilizationPercent: 10 } }); - fsUtil.readFileSync = () => 'MemAvailable: 20000 kB'; + updateProcMemInfo('MemAvailable: 20000 kB'); }) .then(() => clock.clockForward(100, { promisify: true, repeat: 2, delay: 10 })) .then(() => { @@ -338,7 +336,7 @@ describe('Resource Monitor / Memory Monitor', () => { utilizationPercent: 10 } }); - fsUtil.readFileSync = () => 'MemAvai: 20000 kB'; + updateProcMemInfo('MemAvai: 20000 kB'); Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { external: 220 * 1024 * 1024, heapTotal: 220 * 1024 * 1024, @@ -440,7 +438,6 @@ describe('Resource Monitor / Memory Monitor', () => { it('should stop and resume activity', () => { memMon = new MemoryMonitor(callback, { - fs: fsUtil, intervals: [ { usage: 100, interval: 0.1 } ], @@ -485,7 +482,6 @@ describe('Resource Monitor / Memory Monitor', () => { rss: 17 * 1024 * 1024 }); memMon = new MemoryMonitor(callback, { - fs: fsUtil, intervals: [ { usage: 50, interval: 0.5 }, { usage: 90, interval: 0.1 } @@ -505,7 +501,6 @@ describe('Resource Monitor / Memory Monitor', () => { it('should call GC', () => { global.gc = sinon.spy(); memMon = new MemoryMonitor(callback, { - fs: fsUtil, intervals: [ { usage: 50, interval: 0.5 }, { usage: 90, interval: 0.1 } diff --git a/test/unit/resourceMonitor/resourceMonitorTests.js b/test/unit/resourceMonitor/resourceMonitorTests.js index 90764a69..78c2a509 100644 --- a/test/unit/resourceMonitor/resourceMonitorTests.js +++ b/test/unit/resourceMonitor/resourceMonitorTests.js @@ -26,33 +26,51 @@ const sourceCode = require('../shared/sourceCode'); const stubs = require('../shared/stubs'); const APP_THRESHOLDS = sourceCode('src/lib/constants').APP_THRESHOLDS; -const configWorker = sourceCode('src/lib/config'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); const ResourceMonitor = sourceCode('src/lib/resourceMonitor'); moduleCache.remember(); describe('Resource Monitor / Resource Monitor', () => { + let appEvents; + let clock; + let configWorker; let coreStub; let resourceMonitor; let events; + let psEvents; function eraseEvents() { events = { all: [], check: [], notOk: [], - ok: [], - stop: [] + ok: [] }; } + function processDeclaration(decl, sleepOpts, waitForConfig) { + return Promise.all([ + configWorker.processDeclaration(decl), + sleepOpts !== false + ? clock.clockForward( + (sleepOpts || {}).time || 3000, + Object.assign({ promisify: true, delay: 1, repeat: 10 }, sleepOpts || {}) + ) + : Promise.resolve(), + waitForConfig !== false + ? appEvents.waitFor('resmon.config.applied') + : Promise.resolve() + ]); + } + before(() => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { eraseEvents(); + psEvents = []; + resourceMonitor = new ResourceMonitor(); resourceMonitor.ee.on(APP_THRESHOLDS.MEMORY.STATE.NOT_OK, (stats) => { const evt = { name: 'notOk', stats }; @@ -64,11 +82,6 @@ describe('Resource Monitor / Resource Monitor', () => { events.ok.push(evt); events.all.push(evt); }); - resourceMonitor.ee.on('memoryMonitorStop', () => { - const evt = { name: 'stop' }; - events.stop.push(evt); - events.all.push(evt); - }); resourceMonitor.ee.on('memoryCheckStatus', (stats) => { const evt = { name: 'check', stats }; events.check.push(evt); @@ -76,7 +89,6 @@ describe('Resource Monitor / Resource Monitor', () => { }); coreStub = stubs.default.coreStub({}, { logger: { ignoreLevelChange: false } }); - coreStub.persistentStorage.loadData = { config: { } }; coreStub.utilMisc.generateUuid.numbersOnly = false; Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { external: 10 * 1024 * 1024, @@ -86,15 +98,23 @@ describe('Resource Monitor / Resource Monitor', () => { }); coreStub.resourceMonitorUtils.osAvailableMem.free = 500; - return configWorker.cleanup() - .then(() => persistentStorage.persistentStorage.load()) - .then(() => resourceMonitor.initialize({ configMgr: configWorker })); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + resourceMonitor.initialize(appEvents); + appEvents.on('resmon.pstate', (getPState) => psEvents.push(getPState)); + + await coreStub.startServices(); + await configWorker.cleanup(); }); - afterEach(() => resourceMonitor.destroy() - .then(() => { - sinon.restore(); - })); + afterEach(async () => { + await resourceMonitor.destroy(); + await coreStub.destroyServices(); + + appEvents.stop(); + sinon.restore(); + }); describe('constructor', () => { it('should create a new instance', () => { @@ -115,14 +135,13 @@ describe('Resource Monitor / Resource Monitor', () => { }); describe('lifecycle', () => { - let clock; - beforeEach(() => { clock = stubs.clock(); }); it('should ignore changes in configuration when destroyed', () => resourceMonitor.start() .then(() => { + assert.lengthOf(psEvents, 1); assert.isTrue(resourceMonitor.isRunning()); assert.isFalse(resourceMonitor.isMemoryMonitorActive); assert.isTrue(resourceMonitor.isProcessingEnabled()); @@ -133,17 +152,15 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isFalse(resourceMonitor.isRunning()); assert.isFalse(resourceMonitor.isMemoryMonitorActive); assert.isTrue(resourceMonitor.isProcessingEnabled()); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + } + }, {}, false); }) .then(() => { + assert.lengthOf(psEvents, 1); assert.isTrue(resourceMonitor.isDestroyed()); assert.isFalse(resourceMonitor.isRunning()); assert.isTrue(resourceMonitor.isProcessingEnabled()); @@ -176,16 +193,14 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isEmpty(events.all); })); - it('should not generate log messages when log level is not debug or verbose', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + it('should not generate log messages when log level is not debug or verbose', () => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + } + }) .then(() => { + assert.lengthOf(psEvents, 1); coreStub.logger.setLogLevel('verbose'); coreStub.logger.removeAllMessages(); @@ -219,28 +234,26 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isEmpty(coreStub.logger.messages.debug); coreStub.logger.setLogLevel('error'); coreStub.logger.removeAllMessages(); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryMonitor: { - logLevel: 'error', - logFrequency: 30 - } - }, - listener: { - class: 'Telemetry_Listener' + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + logLevel: 'error', + logFrequency: 30 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + }, + listener: { + class: 'Telemetry_Listener' + } + }); }) .then(() => { coreStub.logger.removeAllMessages(); return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 11 }); }) .then(() => { + assert.lengthOf(psEvents, 1); assert.lengthOf(coreStub.logger.messages.error, 1); assert.isEmpty(coreStub.logger.messages.verbose); assert.isEmpty(coreStub.logger.messages.debug); @@ -248,12 +261,9 @@ describe('Resource Monitor / Resource Monitor', () => { assert.includeMatch(coreStub.logger.messages.error, /MEMORY_USAGE_BELOW_THRESHOLD/); })); - it('should work according to declaration content', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry' - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + it('should work according to declaration content', () => processDeclaration({ + class: 'Telemetry' + }) .then(() => { assert.isFalse(resourceMonitor.isRunning()); assert.isFalse(resourceMonitor.isMemoryMonitorActive); @@ -277,15 +287,12 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isNull(resourceMonitor.memoryState); assert.isEmpty(events.all); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + } + }); }) .then(() => { assert.isTrue(resourceMonitor.isRunning()); @@ -320,19 +327,15 @@ describe('Resource Monitor / Resource Monitor', () => { } }); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry' - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + return processDeclaration({ + class: 'Telemetry' + }); }) .then(() => { assert.isTrue(resourceMonitor.isRunning()); assert.isFalse(resourceMonitor.isMemoryMonitorActive); assert.isNull(resourceMonitor.memoryState); assert.isTrue(resourceMonitor.isProcessingEnabled()); - assert.lengthOf(events.stop, 1); eraseEvents(); return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); @@ -345,15 +348,12 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isTrue(resourceMonitor.isProcessingEnabled()); })); - it('should use `warning` level when status changed', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + it('should use `warning` level when status changed', () => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + } + }) .then(() => { assert.isNotEmpty(events.ok); assert.isEmpty(events.notOk); @@ -411,48 +411,39 @@ describe('Resource Monitor / Resource Monitor', () => { ), 1); })); - it('should apply custom configuration from declaration', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry' - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) - .then(() => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryThresholdPercent: 90, - memoryMonitor: { - provisionedMemory: 500 - } + it('should apply custom configuration from declaration', () => processDeclaration({ + class: 'Telemetry' + }) + .then(() => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryThresholdPercent: 90, + memoryMonitor: { + provisionedMemory: 500 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ])) + } + })) .then(() => { assert.isFalse(resourceMonitor.isRunning()); assert.isFalse(resourceMonitor.isMemoryMonitorActive); assert.isNull(resourceMonitor.memoryState); assert.isTrue(resourceMonitor.isProcessingEnabled()); }) - .then(() => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryThresholdPercent: 90, - memoryMonitor: { - provisionedMemory: 500, - thresholdReleasePercent: 80 - } + .then(() => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryThresholdPercent: 90, + memoryMonitor: { + provisionedMemory: 500, + thresholdReleasePercent: 80 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ])) + } + })) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { config: { @@ -539,23 +530,20 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isEmpty(events.notOk); assert.isEmpty(events.ok); assert.isTrue(resourceMonitor.isProcessingEnabled()); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryThresholdPercent: 90, - memoryMonitor: { - provisionedMemory: 500, - memoryThresholdPercent: 70 - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryThresholdPercent: 90, + memoryMonitor: { + provisionedMemory: 500, + memoryThresholdPercent: 70 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + } + }); }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { @@ -585,43 +573,37 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isFalse(resourceMonitor.isProcessingEnabled()); eraseEvents(); }) - .then(() => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryThresholdPercent: 50, - memoryMonitor: { - provisionedMemory: 500 - } + .then(() => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryThresholdPercent: 50, + memoryMonitor: { + provisionedMemory: 500 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ])) + } + })) .then(() => { assert.isEmpty(events.notOk); assert.isEmpty(events.ok); assert.isFalse(resourceMonitor.isProcessingEnabled()); }) - .then(() => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryThresholdPercent: 90, - memoryMonitor: { - provisionedMemory: 500 - } + .then(() => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryThresholdPercent: 90, + memoryMonitor: { + provisionedMemory: 500 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ])) + } + })) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { config: { @@ -647,29 +629,25 @@ describe('Resource Monitor / Resource Monitor', () => { }); assert.isEmpty(events.notOk); assert.isNotEmpty(events.ok); - assert.isEmpty(events.stop); assert.isTrue(resourceMonitor.isProcessingEnabled()); })); it('should log a message when provisioned is more that configured', () => { coreStub.utilMisc.getRuntimeInfo.maxHeapSize = 1000; - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryThresholdPercent: 100, - memoryMonitor: { - provisionedMemory: 1300, - interval: 'aggressive' - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryThresholdPercent: 100, + memoryMonitor: { + provisionedMemory: 1300, + interval: 'aggressive' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + } + }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { config: { @@ -698,49 +676,42 @@ describe('Resource Monitor / Resource Monitor', () => { assert.includeMatch(coreStub.logger.messages.all, /Disabling Memory Monitor due high threshold percent value/); assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig.config.provisioned, 1000); assert.isTrue(resourceMonitor.isProcessingEnabled()); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryMonitor: { - provisionedMemory: 1300, - memoryThresholdPercent: 100 - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 1300, + memoryThresholdPercent: 100 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + } + }); }) .then(() => { assert.includeMatch(coreStub.logger.messages.all, /Please, adjust memory limit/); assert.includeMatch(coreStub.logger.messages.all, /Disabling Memory Monitor due high threshold percent value/); - assert.isEmpty(events.stop); assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig.config.provisioned, 1000); assert.isTrue(resourceMonitor.isProcessingEnabled()); }); }); - it('should notify when not enough OS free memory', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryMonitor: { - osFreeMemory: 50, - interval: 'aggressive', - memoryThresholdPercent: 80 - } + it('should notify when not enough OS free memory', () => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryMonitor: { + osFreeMemory: 50, + interval: 'aggressive', + memoryThresholdPercent: 80 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + } + }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { config: { @@ -782,22 +753,19 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isNotEmpty(events.ok); assert.isTrue(resourceMonitor.isProcessingEnabled()); eraseEvents(); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - memoryMonitor: { - interval: 'aggressive', - memoryThresholdPercent: 80 - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + memoryMonitor: { + interval: 'aggressive', + memoryThresholdPercent: 80 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + } + }); }) .then(() => { assert.isEmpty(events.notOk); @@ -816,22 +784,18 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isTrue(resourceMonitor.isProcessingEnabled()); assert.isEmpty(events.notOk); assert.isNotEmpty(events.ok); - assert.isEmpty(events.stop); })); - it('should update check intervals according to declaration', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - logLevel: 'verbose' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + it('should update check intervals according to declaration', () => processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + logLevel: 'verbose' + } + }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { config: { @@ -862,22 +826,19 @@ describe('Resource Monitor / Resource Monitor', () => { // default interval is 1.5 for low pressure. for 30seconds we should have at least 10 assert.isBelow(events.check.length, 25); eraseEvents(); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - logLevel: 'verbose', - memoryMonitor: { - interval: 'aggressive' - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + interval: 'aggressive' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + } + }); }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { @@ -909,22 +870,19 @@ describe('Resource Monitor / Resource Monitor', () => { // default interval is 0.5 for low pressure. for 30seconds we should have at least 20 assert.isAbove(events.check.length, 35); eraseEvents(); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - listener: { - class: 'Telemetry_Listener' - }, - controls: { - class: 'Controls', - logLevel: 'verbose', - memoryMonitor: { - interval: 'default' - } + return processDeclaration({ + class: 'Telemetry', + listener: { + class: 'Telemetry_Listener' + }, + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + interval: 'default' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + } + }); }) .then(() => { assert.deepStrictEqual(resourceMonitor.memoryMonitorConfig, { @@ -960,7 +918,6 @@ describe('Resource Monitor / Resource Monitor', () => { describe('ProcessingState', () => { let cbEvents; - let clock; let onDisableCb; let onEnableCb; let ps; @@ -974,6 +931,7 @@ describe('Resource Monitor / Resource Monitor', () => { beforeEach(() => { clock = stubs.clock(); + ps = null; onDisableCb = () => { const memState = ps.memoryState; @@ -981,56 +939,55 @@ describe('Resource Monitor / Resource Monitor', () => { cbEvents.onDisable.push(memState); }; onEnableCb = () => { - const memState = ps.memoryState; + const memState = ps.destroyed === false && ps.memoryState; if (memState) { assert.deepStrictEqual(memState.thresholdStatus, APP_THRESHOLDS.MEMORY.STATE.OK); } cbEvents.onEnable.push(memState); }; - ps = resourceMonitor.initializePState(onEnableCb, onDisableCb); + appEvents.on('resmon.pstate', (getPState) => { + assert.isNull(ps, 'should not raise event more than once'); + ps = getPState(onEnableCb, onDisableCb); + assert.isFalse(ps.destroyed); + }); eraseCbEvents(); + return resourceMonitor.start(); }); it('should allow processing once created', () => { + assert.lengthOf(psEvents, 1); assert.isTrue(ps.enabled); - }); - - it('should not call callbacks on initialization', () => { - ps.initialize(onEnableCb, onDisableCb); - assert.isEmpty(cbEvents.onDisable); - assert.isEmpty(cbEvents.onEnable); return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) .then(() => { + assert.isTrue(ps.enabled); assert.isEmpty(cbEvents.onDisable); - assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onEnable, 1); }); }); - it('should change state according to memory usage', () => Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryMonitor: { - provisionedMemory: 300, - memoryThresholdPercent: 90 - } - }, - listener: { - class: 'Telemetry_Listener' + it('should change state according to memory usage', () => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + }, + listener: { + class: 'Telemetry_Listener' + } + }) .then(() => { assert.isTrue(ps.enabled); // should not call callbacks if enabled already - assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onEnable, 1); assert.isEmpty(cbEvents.onDisable); assert.isNotEmpty(events.all); + eraseCbEvents(); Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { external: 290 * 1024 * 1024, @@ -1039,8 +996,6 @@ describe('Resource Monitor / Resource Monitor', () => { rss: 290 * 1024 * 1024 }); - ps.initialize(onEnableCb, onDisableCb); - assert.isTrue(ps.enabled); return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); }) .then(() => { @@ -1051,120 +1006,166 @@ describe('Resource Monitor / Resource Monitor', () => { return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); })); - it('should change state according to memory usage', () => { - ps.initialize(onEnableCb, onDisableCb); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { + it('should change state according to memory usage', () => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }) + .then(() => { + assert.isTrue(ps.enabled); + // should not call callbacks if enabled already + assert.lengthOf(cbEvents.onEnable, 1); + assert.isEmpty(cbEvents.onDisable); + assert.isNotEmpty(events.all); + eraseCbEvents(); + }) + .then(() => { + Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { + external: 290 * 1024 * 1024, + heapTotal: 290 * 1024 * 1024, + heapUsed: 290 * 1024 * 1024, + rss: 290 * 1024 * 1024 + }); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { + external: 100 * 1024 * 1024, + heapTotal: 100 * 1024 * 1024, + heapUsed: 100 * 1024 * 1024, + rss: 100 * 1024 * 1024 + }); + eraseCbEvents(); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onDisable); + assert.lengthOf(cbEvents.onEnable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onDisable); + assert.lengthOf(cbEvents.onEnable, 1); + assert.isNotEmpty(events.all); + + eraseCbEvents(); + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + coreStub.resourceMonitorUtils.osAvailableMem.free = 500; + eraseCbEvents(); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onDisable); + assert.lengthOf(cbEvents.onEnable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onDisable); + assert.lengthOf(cbEvents.onEnable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + })); + + it('should enable processing when memory monitor deactivated', () => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }) + .then(() => { + assert.isTrue(ps.enabled); + // should not call callbacks if enabled already + assert.lengthOf(cbEvents.onEnable, 1); + assert.isEmpty(cbEvents.onDisable); + assert.isNotEmpty(events.all); + eraseCbEvents(); + + return processDeclaration({ + class: 'Telemetry', + controls: { class: 'Controls', memoryMonitor: { provisionedMemory: 300, memoryThresholdPercent: 90 } - }, - listener: { - class: 'Telemetry_Listener' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) - .then(() => { - assert.isTrue(ps.enabled); - // should not call callbacks if enabled already - assert.isEmpty(cbEvents.onEnable); - assert.isEmpty(cbEvents.onDisable); - assert.isNotEmpty(events.all); - }) - .then(() => { - Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { - external: 290 * 1024 * 1024, - heapTotal: 290 * 1024 * 1024, - heapUsed: 290 * 1024 * 1024, - rss: 290 * 1024 * 1024 - }); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { - external: 100 * 1024 * 1024, - heapTotal: 100 * 1024 * 1024, - heapUsed: 100 * 1024 * 1024, - rss: 100 * 1024 * 1024 - }); - eraseCbEvents(); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onDisable); - assert.lengthOf(cbEvents.onEnable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onDisable); - assert.lengthOf(cbEvents.onEnable, 1); - assert.isNotEmpty(events.all); - - eraseCbEvents(); - coreStub.resourceMonitorUtils.osAvailableMem.free = 20; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 500; - eraseCbEvents(); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onDisable); - assert.lengthOf(cbEvents.onEnable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onDisable); - assert.lengthOf(cbEvents.onEnable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); }); - }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.isEmpty(cbEvents.onDisable); + assert.isNotEmpty(events.all); + eraseEvents(); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.isEmpty(cbEvents.onDisable); + assert.isEmpty(events.all); + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.isEmpty(cbEvents.onDisable); + assert.isEmpty(events.all); - it('should change enable processing when memory monitor deactivated', () => { - ps.initialize(onEnableCb, onDisableCb); - return Promise.all([ - configWorker.processDeclaration({ + return processDeclaration({ class: 'Telemetry', controls: { class: 'Controls', @@ -1176,113 +1177,105 @@ describe('Resource Monitor / Resource Monitor', () => { listener: { class: 'Telemetry_Listener' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) - .then(() => { - assert.isTrue(ps.enabled); - // should not call callbacks if enabled already - assert.isEmpty(cbEvents.onEnable); - assert.isEmpty(cbEvents.onDisable); - assert.isNotEmpty(events.all); + }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryMonitor: { - provisionedMemory: 300, - memoryThresholdPercent: 90 - } - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.isEmpty(cbEvents.onDisable); - assert.isNotEmpty(events.all); - eraseEvents(); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.isEmpty(cbEvents.onDisable); - assert.isEmpty(events.all); - coreStub.resourceMonitorUtils.osAvailableMem.free = 20; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.isEmpty(cbEvents.onDisable); - assert.isEmpty(events.all); + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + } + }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + })); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryMonitor: { - provisionedMemory: 300, - memoryThresholdPercent: 90 - } - }, - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); + it('should enable processing when memory monitor deactivated (high mem usage)', () => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }) + .then(() => { + assert.lengthOf(psEvents, 1); + assert.isTrue(ps.enabled); + // should not call callbacks if enabled already + assert.lengthOf(cbEvents.onEnable, 1); + assert.isEmpty(cbEvents.onDisable); + assert.isNotEmpty(events.all); + eraseCbEvents(); + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - memoryMonitor: { - provisionedMemory: 300, - memoryThresholdPercent: 90 - } - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); - }) - .then(() => { - assert.isTrue(ps.enabled); - assert.lengthOf(cbEvents.onEnable, 1); - assert.lengthOf(cbEvents.onDisable, 1); - assert.isNotEmpty(events.all); + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + } }); - }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + eraseEvents(); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isEmpty(events.all); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isEmpty(events.all); + eraseCbEvents(); - it('should enable processing once resource monitor destroyed', () => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 20; - ps.initialize(onEnableCb, onDisableCb); - return Promise.all([ - configWorker.processDeclaration({ + return processDeclaration({ class: 'Telemetry', controls: { class: 'Controls', - logLevel: 'verbose', memoryMonitor: { provisionedMemory: 300, memoryThresholdPercent: 90 @@ -1291,20 +1284,45 @@ describe('Resource Monitor / Resource Monitor', () => { listener: { class: 'Telemetry_Listener' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + }); + }) + .then(() => { + assert.lengthOf(psEvents, 1); + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + assert.isNotEmpty(events.all); + })); + + it('should enable processing once resource monitor destroyed', () => { + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }) .then(() => { + assert.lengthOf(psEvents, 1); assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onEnable, 1); assert.lengthOf(cbEvents.onDisable, 1); assert.isNotEmpty(events.all); + eraseCbEvents(); return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); }) .then(() => { assert.isFalse(ps.enabled); assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); + assert.isEmpty(cbEvents.onDisable); assert.isNotEmpty(events.all); return resourceMonitor.destroy(); }) @@ -1312,15 +1330,36 @@ describe('Resource Monitor / Resource Monitor', () => { .then(() => { assert.isTrue(ps.enabled); assert.lengthOf(cbEvents.onEnable, 1); - assert.lengthOf(cbEvents.onDisable, 1); + assert.isEmpty(cbEvents.onDisable); assert.isNotEmpty(events.all); }); }); - it('should enable/disable processing according to the state', () => { - ps.initialize(onEnableCb, onDisableCb); - return Promise.all([ - configWorker.processDeclaration({ + it('should enable/disable processing according to the state', () => processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + } + }) + .then(() => clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 })) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.isEmpty(cbEvents.onDisable); + eraseCbEvents(); + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.isEmpty(cbEvents.onDisable); + return processDeclaration({ class: 'Telemetry', controls: { class: 'Controls', @@ -1329,45 +1368,127 @@ describe('Resource Monitor / Resource Monitor', () => { provisionedMemory: 300, memoryThresholdPercent: 90 } + }, + listener: { + class: 'Telemetry_Listener' } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]) + }); + }) + .then(() => clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 })) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onDisable, 1); + coreStub.resourceMonitorUtils.osAvailableMem.free = 500; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 1); + Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { + external: 340 * 1024 * 1024, + heapTotal: 340 * 1024 * 1024, + heapUsed: 340 * 1024 * 1024, + rss: 340 * 1024 * 1024 + }); + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + }) + .then(() => { + assert.isFalse(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 2); + + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 500, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 2); + assert.lengthOf(cbEvents.onDisable, 2); + + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 500, + memoryThresholdPercent: 90 + } + } + }); + }) + .then(() => { + assert.isTrue(ps.enabled); + assert.lengthOf(cbEvents.onEnable, 2); + assert.lengthOf(cbEvents.onDisable, 2); + })); + + it('should restart destroyed instance and restore config', () => { + let oldPs; + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + } + }) .then(() => clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 })) .then(() => { assert.isTrue(ps.enabled); - assert.isEmpty(cbEvents.onEnable); + assert.lengthOf(cbEvents.onEnable, 1); assert.isEmpty(cbEvents.onDisable); - coreStub.resourceMonitorUtils.osAvailableMem.free = 20; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + eraseCbEvents(); + + return processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + logLevel: 'verbose', + memoryMonitor: { + provisionedMemory: 300, + memoryThresholdPercent: 90 + } + }, + listener: { + class: 'Telemetry_Listener' + } + }); }) .then(() => { assert.isTrue(ps.enabled); assert.isEmpty(cbEvents.onEnable); assert.isEmpty(cbEvents.onDisable); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - logLevel: 'verbose', - memoryMonitor: { - provisionedMemory: 300, - memoryThresholdPercent: 90 - } - }, - listener: { - class: 'Telemetry_Listener' - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); - }) - .then(() => clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 })) - .then(() => { - assert.isFalse(ps.enabled); - assert.isEmpty(cbEvents.onEnable); - assert.lengthOf(cbEvents.onDisable, 1); + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); }) .then(() => { @@ -1381,67 +1502,46 @@ describe('Resource Monitor / Resource Monitor', () => { assert.isTrue(ps.enabled); assert.lengthOf(cbEvents.onEnable, 1); assert.lengthOf(cbEvents.onDisable, 1); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); + assert.isFalse(ps.destroyed); + eraseCbEvents(); + return resourceMonitor.destroy(); }) .then(() => { assert.isTrue(ps.enabled); - assert.lengthOf(cbEvents.onEnable, 1); - assert.lengthOf(cbEvents.onDisable, 1); - Object.assign(coreStub.resourceMonitorUtils.appMemoryUsage, { - external: 340 * 1024 * 1024, - heapTotal: 340 * 1024 * 1024, - heapUsed: 340 * 1024 * 1024, - rss: 340 * 1024 * 1024 - }); - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isFalse(ps.enabled); - assert.lengthOf(cbEvents.onEnable, 1); - assert.lengthOf(cbEvents.onDisable, 2); + assert.isEmpty(cbEvents.onEnable); + assert.isEmpty(cbEvents.onDisable); + assert.isTrue(ps.destroyed); + assert.doesNotThrow(() => ps.destroy()); + + oldPs = ps; + ps = null; + coreStub.resourceMonitorUtils.osAvailableMem.free = 20; + eraseCbEvents(); + resourceMonitor.initialize(appEvents); return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - logLevel: 'verbose', - memoryMonitor: { - provisionedMemory: 500, - memoryThresholdPercent: 90 - } - }, - listener: { - class: 'Telemetry_Listener' - } - }), + resourceMonitor.restart(), clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) ]); }) .then(() => { + assert.ok(oldPs !== ps); assert.isTrue(ps.enabled); - assert.lengthOf(cbEvents.onEnable, 2); - assert.lengthOf(cbEvents.onDisable, 2); + assert.isTrue(oldPs.enabled); - return Promise.all([ - configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - logLevel: 'verbose', - memoryMonitor: { - provisionedMemory: 500, - memoryThresholdPercent: 90 - } - } - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - ]); + // should enable at start + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 0); + + coreStub.resourceMonitorUtils.osAvailableMem.free = 500; + return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); }) .then(() => { assert.isTrue(ps.enabled); - assert.lengthOf(cbEvents.onEnable, 2); - assert.lengthOf(cbEvents.onDisable, 2); + assert.isTrue(oldPs.enabled); + // should not call destroyed PS instance + assert.lengthOf(cbEvents.onEnable, 1); + assert.lengthOf(cbEvents.onDisable, 0); }); }); }); diff --git a/test/unit/restAPI/handlers/declareTests.js b/test/unit/restAPI/handlers/declareTests.js new file mode 100644 index 00000000..a121d0bc --- /dev/null +++ b/test/unit/restAPI/handlers/declareTests.js @@ -0,0 +1,521 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const dummies = require('../../shared/dummies'); +const restAPIUtils = require('../utils'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); +const testUtils = require('../../shared/util'); + +const RESTAPIService = sourceCode('src/lib/restAPI'); +const RestWorker = sourceCode('src/nodejs/restWorker'); + +moduleCache.remember(); + +// TODO: update tests and the code to be events-driven once Config Worker changes commited to master + +describe('REST API / "/declare" endpoint', () => { + const dlURI = '/declare'; + let configWorker; + let coreStub; + let declaration; + let restAPI; + let restWorker; + + function sendRequest() { + return restAPIUtils.waitRequestComplete( + restWorker, + restAPIUtils.buildRequest.apply(restAPIUtils, arguments) + ); + } + + function processDeclaration(decl, waitFor = true) { + return restAPIUtils.processDeclaration(configWorker, coreStub.appEvents.appEvents, decl, waitFor); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + configWorker = coreStub.configWorker.configWorker; + + restAPI = new RESTAPIService(restAPIUtils.TELEMETRY_URI_PREFIX); + restWorker = new RestWorker(); + + restAPI.initialize(coreStub.appEvents.appEvents); + restWorker.initialize(coreStub.appEvents.appEvents); + + declaration = dummies.declaration.base.decrypted({ + listener: dummies.declaration.listener.minimal.decrypted(), + My_Namespace: dummies.declaration.namespace.base.decrypted({ + listener2: dummies.declaration.listener.minimal.decrypted() + }) + }); + + await coreStub.startServices(); + await restAPI.start(); + + assert.isTrue(restAPI.isRunning()); + + await processDeclaration(testUtils.deepCopy(declaration)); + }); + + afterEach(async () => { + await processDeclaration(testUtils.deepCopy(dummies.declaration.base.decrypted())); + + await coreStub.destroyServices(); + await restAPI.destroy(); + + assert.isTrue(restAPI.isDestroyed()); + sinon.restore(); + }); + + [ + true, + false + ].forEach((useNamespace) => describe(useNamespace ? 'Custom namespace' : 'Default namespace', () => { + const namespace = useNamespace ? 'My_Namespace' : undefined; + const uriPrefix = useNamespace ? `/namespace/${namespace}` : ''; + + it('should get current configuration', async () => { + const restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + if (useNamespace) { + assert.deepStrictEqual(restOp.body.message, 'success'); + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry_Namespace'); + assert.isDefined(restOp.body.declaration.listener2); + assert.deepStrictEqual(restOp.body.declaration.listener2.class, 'Telemetry_Listener'); + } else { + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry'); + assert.isDefined(restOp.body.declaration.schemaVersion); + assert.isDefined(restOp.body.declaration.listener); + assert.deepStrictEqual(restOp.body.declaration.listener.class, 'Telemetry_Listener'); + } + }); + + it('should not be a debug endpoint', async () => { + declaration.controls = dummies.declaration.controls.full.decrypted({ debug: true }); + await processDeclaration(testUtils.deepCopy(declaration)); + + const restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + if (useNamespace) { + assert.deepStrictEqual(restOp.body.message, 'success'); + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry_Namespace'); + assert.isDefined(restOp.body.declaration.listener2); + assert.deepStrictEqual(restOp.body.declaration.listener2.class, 'Telemetry_Listener'); + } else { + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry'); + assert.isDefined(restOp.body.declaration.schemaVersion); + assert.isDefined(restOp.body.declaration.listener); + assert.deepStrictEqual(restOp.body.declaration.listener.class, 'Telemetry_Listener'); + } + }); + + if (!useNamespace) { + it('should return 404 on GET non-existing namespace', async () => { + const restOp = await sendRequest({ path: `/namespace/namespace${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: 404, + error: 'Bad URL: /mgmt/shared/telemetry/namespace/namespace/declare', + message: 'Not Found' + }); + }); + } + + it('should return 422 on POST invalid declaration', async () => { + let restOp = await sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: { data: 'invalid declaration' } + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.UNPROCESSABLE_ENTITY); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: 422, + error: [{ + dataPath: '/data', + keyword: 'type', + message: 'should be object', + params: { + type: 'object' + }, + schemaPath: '#/type' + }], + message: 'Unprocessable entity' + }); + + restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + if (useNamespace) { + assert.deepStrictEqual(restOp.body.message, 'success'); + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry_Namespace'); + assert.isDefined(restOp.body.declaration.listener2); + assert.deepStrictEqual(restOp.body.declaration.listener2.class, 'Telemetry_Listener'); + } else { + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry'); + assert.isDefined(restOp.body.declaration.schemaVersion); + assert.isDefined(restOp.body.declaration.listener); + assert.deepStrictEqual(restOp.body.declaration.listener.class, 'Telemetry_Listener'); + } + }); + + it('should return 200 on POST valid declaration', async () => { + let restOp = await sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: useNamespace + ? dummies.declaration.namespace.base.decrypted() + : dummies.declaration.base.decrypted() + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + if (useNamespace) { + assert.deepStrictEqual(restOp.body.message, 'success'); + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry_Namespace'); + assert.isUndefined(restOp.body.declaration.listener2); + } else { + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry'); + assert.isDefined(restOp.body.declaration.schemaVersion); + assert.isUndefined(restOp.body.declaration.listener); + } + + restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + if (useNamespace) { + assert.deepStrictEqual(restOp.body.message, 'success'); + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry_Namespace'); + assert.isUndefined(restOp.body.declaration.listener2); + } else { + assert.isDefined(restOp.body.declaration); + assert.deepStrictEqual(restOp.body.declaration.class, 'Telemetry'); + assert.isDefined(restOp.body.declaration.schemaVersion); + assert.isUndefined(restOp.body.declaration.listener); + } + }); + + it('should response with 500 when caught error on attempt to GET declaration', async () => { + sinon.stub(configWorker, 'getDeclaration').rejects(new Error('expected error')); + const restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.INTERNAL_SERVER_ERROR); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.INTERNAL_SERVER_ERROR, + error: 'expected error', + message: 'Internal Server Error' + }); + }); + + it('should response with 500 when caught error on attempt to POST declaration', async () => { + const stub = sinon.stub( + configWorker, + useNamespace ? 'processNamespaceDeclaration' : 'processDeclaration' + ); + stub.rejects(new Error('expected error')); + + const restOp = await sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: testUtils.deepCopy(declaration) + }); + + stub.restore(); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.INTERNAL_SERVER_ERROR); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.INTERNAL_SERVER_ERROR, + error: 'expected error', + message: 'Internal Server Error' + }); + }); + + it('should compute metadata for request', async () => { + const spy = sinon.spy( + configWorker, + useNamespace ? 'processNamespaceDeclaration' : 'processDeclaration' + ); + declaration = useNamespace + ? dummies.declaration.namespace.base.decrypted() + : dummies.declaration.base.decrypted(); + + let restOp = await sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: testUtils.deepCopy(declaration) + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + let args = spy.lastCall.args; + let metadata = args[args.length - 1].metadata; + assert.deepStrictEqual(metadata.originDeclaration, declaration); + assert.deepStrictEqual(metadata.message, 'Incoming declaration via REST API'); + assert.deepStrictEqual(metadata.namespace, namespace); + assert.deepStrictEqual(metadata.sourceIP, 'unknown'); + + restOp = await sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: testUtils.deepCopy(declaration), + headers: { 'X-Forwarded-For': 'localhost' } + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + + args = spy.lastCall.args; + metadata = args[args.length - 1].metadata; + assert.deepStrictEqual(metadata.originDeclaration, declaration); + assert.deepStrictEqual(metadata.message, 'Incoming declaration via REST API'); + assert.deepStrictEqual(metadata.namespace, namespace); + assert.deepStrictEqual(metadata.sourceIP, 'localhost'); + }); + + it('should return 503 on attempt to POST declaration while previous one is still in process', async () => { + const stub = sinon.stub( + configWorker, + useNamespace ? 'processNamespaceDeclaration' : 'processDeclaration' + ); + stub.onFirstCall().callsFake(function () { + return testUtils.sleep(50) + .then(() => stub.wrappedMethod.apply(configWorker, arguments)); + }); + stub.callThrough(); + + declaration = useNamespace + ? dummies.declaration.namespace.base.decrypted() + : dummies.declaration.base.decrypted(); + + let id = 0; + const postDeclaration = () => { + const copy = testUtils.deepCopy(declaration); + copy[`listener_${id}`] = dummies.declaration.listener.minimal.decrypted(); + id += 1; + return sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: copy + }); + }; + + const results = await Promise.all([ + postDeclaration(), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(postDeclaration) + ]); + + assert.sameMembers( + results.map((restOp) => restOp.statusCode), + [200, 503, 503, 503] + ); + const restOp = await sendRequest({ path: `${uriPrefix}${dlURI}` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isDefined(restOp.body.declaration.listener_0); + }); + })); + + it('should return 503 on attempt to POST declaration while previous one is still in process (mixed)', async () => { + const pdStub = sinon.stub(configWorker, 'processDeclaration'); + const pnStub = sinon.stub(configWorker, 'processNamespaceDeclaration'); + + pdStub.onFirstCall().callsFake(function () { + return testUtils.sleep(50) + .then(() => pdStub.wrappedMethod.apply(configWorker, arguments)); + }); + pdStub.callThrough(); + pnStub.onFirstCall().callsFake(function () { + return testUtils.sleep(50) + .then(() => pnStub.wrappedMethod.apply(configWorker, arguments)); + }); + pnStub.callThrough(); + + let id = 0; + + const postDeclaration = (namespace) => { + const decl = namespace + ? dummies.declaration.namespace.base.decrypted() + : dummies.declaration.base.decrypted(); + + decl[`listener_${id}`] = dummies.declaration.listener.minimal.decrypted(); + id += 1; + + const uriPrefix = namespace ? `/namespace/${namespace}` : ''; + + return sendRequest({ + path: `${uriPrefix}${dlURI}`, + method: 'POST', + body: decl + }); + }; + + let results = await Promise.all([ + postDeclaration(), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(() => postDeclaration('namespace1')), + testUtils.sleep(10).then(() => postDeclaration('namespace2')) + ]); + + assert.sameMembers( + results.map((restOp) => restOp.statusCode), + [200, 503, 503, 503] + ); + let restOp = await sendRequest({ path: '/declare' }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isDefined(restOp.body.declaration.listener_0); + + pdStub.resetHistory(); + pnStub.resetHistory(); + + results = await Promise.all([ + postDeclaration('namespace1'), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(() => postDeclaration('namespace3')), + testUtils.sleep(10).then(postDeclaration) + ]); + + assert.sameMembers( + results.map((rop) => rop.statusCode), + [200, 503, 503, 503] + ); + + restOp = await sendRequest({ path: '/declare' }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isDefined(restOp.body.declaration.listener_0); + assert.isDefined(restOp.body.declaration.namespace1); + assert.isDefined(restOp.body.declaration.namespace1.listener_4); + + pdStub.resetHistory(); + pnStub.resetHistory(); + + results = await Promise.all([ + postDeclaration('namespace3'), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(() => postDeclaration('namespace1')), + testUtils.sleep(10).then(postDeclaration) + ]); + + assert.sameMembers( + results.map((rop) => rop.statusCode), + [200, 503, 503, 503] + ); + + restOp = await sendRequest({ path: '/declare' }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isDefined(restOp.body.declaration.listener_0); + assert.isDefined(restOp.body.declaration.namespace1); + assert.isDefined(restOp.body.declaration.namespace1.listener_4); + assert.isDefined(restOp.body.declaration.namespace3); + assert.isDefined(restOp.body.declaration.namespace3.listener_8); + + pdStub.resetHistory(); + pnStub.resetHistory(); + + results = await Promise.all([ + postDeclaration('namespace3'), + testUtils.sleep(10).then(postDeclaration), + testUtils.sleep(10).then(() => postDeclaration('namespace1')), + testUtils.sleep(10).then(postDeclaration) + ]); + + assert.sameMembers( + results.map((rop) => rop.statusCode), + [200, 503, 503, 503] + ); + + restOp = await sendRequest({ path: '/declare' }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isDefined(restOp.body.declaration.listener_0); + assert.isDefined(restOp.body.declaration.namespace1); + assert.isDefined(restOp.body.declaration.namespace1.listener_4); + assert.isDefined(restOp.body.declaration.namespace3); + assert.isUndefined(restOp.body.declaration.namespace3.listener_8); + assert.isDefined(restOp.body.declaration.namespace3.listener_12); + + pdStub.resetHistory(); + pnStub.resetHistory(); + + results = await Promise.all([ + postDeclaration(), + testUtils.sleep(10).then(() => postDeclaration('namespace3')), + testUtils.sleep(10).then(() => postDeclaration('namespace1')), + testUtils.sleep(10).then(postDeclaration) + ]); + + assert.sameMembers( + results.map((rop) => rop.statusCode), + [200, 503, 503, 503] + ); + restOp = await sendRequest({ path: '/declare' }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.isUndefined(restOp.body.declaration.listener_0); + assert.isDefined(restOp.body.declaration.listener_16); + assert.isUndefined(restOp.body.declaration.namespace1); + assert.isUndefined(restOp.body.declaration.namespace3); + }); +}); diff --git a/test/unit/restAPI/handlers/eventListenerTests.js b/test/unit/restAPI/handlers/eventListenerTests.js new file mode 100644 index 00000000..e175c730 --- /dev/null +++ b/test/unit/restAPI/handlers/eventListenerTests.js @@ -0,0 +1,157 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const restAPIUtils = require('../utils'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); + +const errors = sourceCode('src/lib/errors'); +const EventListenerPublisher = sourceCode('src/lib/eventListener/dataPublisher'); +const RESTAPIService = sourceCode('src/lib/restAPI'); +const RestWorker = sourceCode('src/nodejs/restWorker'); + +moduleCache.remember(); + +// TODO: update tests and the code to be events-driven once Event Listener changes commited to master + +describe('REST API / "/eventListener" endpoint', () => { + const elURI = '/eventListener'; + let configWorker; + let coreStub; + let restAPI; + let restWorker; + let sendDataStub; + + function sendRequest() { + return restAPIUtils.waitRequestComplete( + restWorker, + restAPIUtils.buildRequest.apply(restAPIUtils, arguments) + ); + } + + function processDeclaration(decl, waitFor = true) { + return restAPIUtils.processDeclaration(configWorker, coreStub.appEvents.appEvents, decl, waitFor); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + configWorker = coreStub.configWorker.configWorker; + + restAPI = new RESTAPIService(restAPIUtils.TELEMETRY_URI_PREFIX); + restWorker = new RestWorker(); + + restAPI.initialize(coreStub.appEvents.appEvents); + restWorker.initialize(coreStub.appEvents.appEvents); + + sendDataStub = sinon.stub(EventListenerPublisher, 'sendDataToListener').resolves(); + + await coreStub.startServices(); + await restAPI.start(); + + assert.isTrue(restAPI.isRunning()); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + }); + + afterEach(async () => { + await coreStub.destroyServices(); + await restAPI.destroy(); + + assert.isTrue(restAPI.isDestroyed()); + sinon.restore(); + }); + + [ + true, + false + ].forEach((useNamespace) => describe(useNamespace ? 'Custom namespace' : 'Default namespace', () => { + const namespace = useNamespace ? 'My_Namespace' : undefined; + const uriPrefix = useNamespace ? `/namespace/${namespace}` : ''; + + it('should send data to event listener', async () => { + const restOp = await sendRequest({ + path: `${uriPrefix}${elURI}/My_Listener`, + method: 'POST', + body: { data: 'testData' } + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + message: 'success', + data: { + data: 'testData' + } + }); + assert.strictEqual(sendDataStub.callCount, 1, 'should be called once'); + assert.deepStrictEqual( + sendDataStub.firstCall.args, + [{ data: 'testData' }, 'My_Listener', { namespace }], + 'should be called once' + ); + }); + + it('should return 404 if event listener not found', async () => { + sendDataStub.rejects(new errors.ConfigLookupError('listener not found')); + const restOp = await sendRequest({ + path: `${uriPrefix}${elURI}/Non_Ex_Listener`, + method: 'POST', + body: { data: 'testData' } + }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: restAPIUtils.HTTP_CODES.NOT_FOUND, + message: 'Not Found', + error: `Bad URL: /mgmt/shared/telemetry${uriPrefix}${elURI}/Non_Ex_Listener` + }); + }); + + it('should be a debug endpoint only', async () => { + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: false + } + }); + + const restOp = await sendRequest({ path: `${uriPrefix}${elURI}/My_Listener` }); + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + }); + })); +}); diff --git a/test/unit/restAPI/handlers/infoTests.js b/test/unit/restAPI/handlers/infoTests.js new file mode 100644 index 00000000..c39092e2 --- /dev/null +++ b/test/unit/restAPI/handlers/infoTests.js @@ -0,0 +1,96 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../../shared/assert'); +const restAPIUtils = require('../utils'); +const sourceCode = require('../../shared/sourceCode'); +const stubs = require('../../shared/stubs'); + +const packageJson = sourceCode('package.json'); +const RESTAPIService = sourceCode('src/lib/restAPI'); +const RestWorker = sourceCode('src/nodejs/restWorker'); +const schemaJson = sourceCode('src/schema/latest/base_schema.json'); + +moduleCache.remember(); + +describe('REST API / "/info" endpoint', () => { + const inURI = '/info'; + let coreStub; + let restAPI; + let restWorker; + + function sendRequest() { + return restAPIUtils.waitRequestComplete( + restWorker, + restAPIUtils.buildRequest.apply(restAPIUtils, arguments) + ); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub({ appEvents: true }); + + restAPI = new RESTAPIService(restAPIUtils.TELEMETRY_URI_PREFIX); + restWorker = new RestWorker(); + + restAPI.initialize(coreStub.appEvents.appEvents); + restWorker.initialize(coreStub.appEvents.appEvents); + + await coreStub.startServices(); + await restAPI.start(); + + assert.isTrue(restAPI.isRunning()); + }); + + afterEach(async () => { + await coreStub.destroyServices(); + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + sinon.restore(); + }); + + it('should return info data on GET request (real data)', async () => { + const restOp = await sendRequest({ path: inURI }); + + const pkgInfo = packageJson.version.split('-'); + const schemaInfo = schemaJson.properties.schemaVersion.enum; + + assert.deepStrictEqual(restOp.statusCode, restAPIUtils.HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, restAPIUtils.CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + branch: 'gitbranch', + buildID: 'githash', + buildTimestamp: 'buildtimestamp', + fullVersion: packageJson.version, + nodeVersion: process.version, + release: pkgInfo[1], + schemaCurrent: schemaInfo[0], + schemaMinimum: schemaInfo[schemaInfo.length - 1], + version: pkgInfo[0] + }); + }); +}); diff --git a/test/unit/restAPI/restAPIServiceTests.js b/test/unit/restAPI/restAPIServiceTests.js new file mode 100644 index 00000000..fa1b61e1 --- /dev/null +++ b/test/unit/restAPI/restAPIServiceTests.js @@ -0,0 +1,934 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const pathUtil = require('path'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); +const restAPIUtils = require('./utils'); + +const RESTAPIService = sourceCode('src/lib/restAPI'); +const RestWorker = sourceCode('src/nodejs/restWorker'); + +moduleCache.remember(); + +describe('REST API / REST API', () => { + const CONTENT_TYPE = restAPIUtils.CONTENT_TYPE; + const HTTP_CODES = restAPIUtils.HTTP_CODES; + const testHandlers = pathUtil.join(__dirname, 'testHandlers'); + let appEvents; + let configWorker; + let coreStub; + let defaultContentType; + let restAPI; + let restWorker; + let uriPrefix = '/mgmt/shared/telemetry/'; + + function getBaseURI() { + const prefix = uriPrefix.startsWith('/') + ? uriPrefix + : `/${uriPrefix}`; + return `http://localhost:8100${prefix}`; + } + + function getContentType() { + return defaultContentType; + } + + function processDeclaration(decl, waitFor = true) { + return restAPIUtils.processDeclaration(configWorker, appEvents, decl, waitFor); + } + + function sendRequest({ + body = undefined, + contentType = getContentType(), + headers = undefined, + method = undefined, + params = undefined, + path = undefined, + rootURI = getBaseURI() + }) { + const request = restAPIUtils.buildRequest({ + path, method, params, body, contentType, headers, rootURI + }); + return restAPIUtils.waitRequestComplete(restWorker, request); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + defaultContentType = 'application/json'; + + restAPI = new RESTAPIService(uriPrefix); + restWorker = new RestWorker(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + await coreStub.startServices(); + }); + + afterEach(async () => { + await coreStub.destroyServices(); + await restAPI.destroy(); + + assert.isTrue(restAPI.isDestroyed()); + sinon.restore(); + }); + + describe('constructor', () => { + it('should fail to load handlers', () => { + assert.throws( + () => new RESTAPIService(undefined, 'non-existing-handlers'), + /Unable to load handlers from 'non-existing-handlers'/ + ); + }); + }); + + describe('REST API endpoints', () => { + beforeEach(async () => { + uriPrefix = '/service'; + restAPI = new RESTAPIService(uriPrefix, testHandlers); + + restAPI.initialize(appEvents); + restWorker.initialize(appEvents); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + await processDeclaration({ class: 'Telemetry' }); + }); + + describe('error handling', () => { + it('should not register invalid handlers', async () => { + const restOp = await sendRequest({ path: '/badinterface', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.NOT_FOUND, + error: 'Bad URL: /service/badinterface', + message: 'Not Found' + }); + }); + + it('should return 415 when has body with invalid content type', async () => { + let restOp = await sendRequest({ + path: 'something', + method: 'POST', + body: { body: true }, + contentType: 'application/something' + }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.UNSUPPORTED_MEDIA_TYPE); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.UNSUPPORTED_MEDIA_TYPE, + error: 'Accepted Content-Type: application/json', + message: 'Unsupported Media Type' + }); + + restOp = await sendRequest({ + path: '/regular', + method: 'POST', + body: { body: true }, + contentType: CONTENT_TYPE.JSON + }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + body: { + body: true + }, + method: 'POST' + }); + }); + + it('should return 405 when request send with unsupported mehod', async () => { + const restOp = await sendRequest({ path: '/regular/another', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.METHOD_NOT_ALLOWED, + error: 'Allowed methods: GET', + message: 'Method Not Allowed' + }); + }); + }); + + ['DELETE', 'GET', 'POST'] + .forEach((method) => it(`should register endpoint with multiple methods (${method})`, + async () => { + const restOp = await sendRequest({ path: '/regular', method }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + method + }); + })); + + [ + undefined, // use default value '' + '', + '/', + 'something', + 'something/', + '/prefix/', + '/prefix/path', + '/prefix/path/' + ].forEach((prefix) => it(`should be able to use custom URI prefix - '${prefix}'`, async () => { + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + uriPrefix = prefix || ''; + restAPI = new RESTAPIService(prefix, testHandlers); + restAPI.initialize(appEvents); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + let restOp = await sendRequest({ path: '/regular' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + method: 'GET' + }); + + restOp = await sendRequest({ path: 'noleadingslash' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + method: 'GET' + }); + })); + + it('should catch and log errors on attempt to deregister handlers', async () => { + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + restAPI = new RESTAPIService(uriPrefix, testHandlers); + restAPI.initialize(appEvents); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + assert.includeMatch( + coreStub.logger.messages.error, + /expected sync destroy error/ + ); + assert.includeMatch( + coreStub.logger.messages.error, + /expected async destroy error/ + ); + }); + + it('should pass configuration object to "register" callbacks', async () => { + let restOp = await sendRequest({ path: '/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.NOT_FOUND, + error: 'Bad URL: /service/debug', + message: 'Not Found' + }); + + restOp = await sendRequest({ path: '/regular/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.METHOD_NOT_ALLOWED, + error: 'Allowed methods: GET', + message: 'Method Not Allowed' + }); + + restOp = await sendRequest({ path: '/regular/debug' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'GET', + debug: true + }); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + restOp = await sendRequest({ path: '/regular/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'POST', + debug: true + }); + + restOp = await sendRequest({ path: '/regular/debug' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'GET', + debug: true + }); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, undefined); + + await processDeclaration({ + class: 'Telemetry' + }); + + restOp = await sendRequest({ path: '/regular/debug' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'GET', + debug: true + }); + + restOp = await sendRequest({ path: '/regular/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.METHOD_NOT_ALLOWED, + error: 'Allowed methods: GET', + message: 'Method Not Allowed' + }); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.NOT_FOUND, + error: 'Bad URL: /service/debug', + message: 'Not Found' + }); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + restOp = await sendRequest({ path: '/regular/debug', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'POST', + debug: true + }); + + restOp = await sendRequest({ path: '/regular/debug' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, { + method: 'GET', + debug: true + }); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, ''); + assert.deepStrictEqual(restOp.body, undefined); + }); + + it('should return 500 when response code not set', async () => { + const restOp = await sendRequest({ path: '/regular?nocode=true' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.INTERNAL_SERVER_ERROR); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.INTERNAL_SERVER_ERROR, + message: 'Internal Server Error' + }); + }); + + it('should parse URI params', async () => { + let restOp = await sendRequest({ path: '/regular/required' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.NOT_FOUND, + error: 'Bad URL: /service/regular/required', + message: 'Not Found' + }); + + restOp = await sendRequest({ path: '/regular/required1/subpath/required2' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + params: { + required1: 'required1', + required2: 'required2' + } + }); + + restOp = await sendRequest({ path: '/regular/required1/subpath/required2/optional' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + params: { + required1: 'required1', + required2: 'required2', + optional: 'optional' + } + }); + + restOp = await sendRequest({ path: '/regular/required1/subpath/required2/optional1/optional2' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.NOT_FOUND, + error: 'Bad URL: /service/regular/required1/subpath/required2/optional1/optional2', + message: 'Not Found' + }); + }); + + [ + 'throw', + 'reject' + ].forEach((faultyWay) => it('should response with 500 on unexpected error', async () => { + const restOp = await sendRequest({ path: `/faulty?${faultyWay}=true`, method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.INTERNAL_SERVER_ERROR); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + code: HTTP_CODES.INTERNAL_SERVER_ERROR, + message: 'Internal Server Error', + error: `expected error - ${faultyWay}` + }); + })); + + [ + 205, + 300, + 490, + 540 + ].forEach((code) => it(`should set customer resposne code - ${code}`, async () => { + const restOp = await sendRequest({ path: `/regular?code=${code}` }); + + assert.deepStrictEqual(restOp.statusCode, code); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { method: 'GET' }); + })); + + it('should read headers', async () => { + const restOp = await sendRequest({ path: '/regular?headers=true', headers: { header: 'value' } }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.contentType, CONTENT_TYPE.JSON); + assert.deepStrictEqual(restOp.body, { + method: 'GET', + headers: { + header: 'value' + } + }); + }); + + it('should preserve configuration between on stop/restart', async () => { + let restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await restAPI.stop(); + assert.isFalse(restAPI.isRunning()); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + + await restAPI.stop(); + assert.isFalse(restAPI.isRunning()); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: false + } + }); + + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await restAPI.stop(); + assert.isFalse(restAPI.isRunning()); + + await restAPI.start(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + }); + + it('should catch errors when applying configuaration', async () => { + sinon.stub(restAPI, 'restart').rejects(new Error('expected restart error')); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + assert.includeMatch(coreStub.logger.messages.error, /Error caught on attempt to apply configuration to REST API Service/); + assert.includeMatch(coreStub.logger.messages.error, /expected restart error/gm); + }); + + it('should reset configuration once destroyed', async () => { + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + let restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.SERVICE_UNAVAILABLE); + + restAPI.initialize(appEvents); + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + + restOp = await sendRequest({ path: '/debug', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + }); + + it('should not respond to config updates once destroyed', async () => { + let restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.NOT_FOUND); + + await restAPI.destroy(); + assert.isTrue(restAPI.isDestroyed()); + + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }, false); + + await testUtil.sleep(100); + assert.isTrue(restAPI.isDestroyed()); + + restOp = await sendRequest({ path: '/debug?setcode=true', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.SERVICE_UNAVAILABLE); + }); + + it('should not process a request more than once', async () => { + const cloneRestAPI = new RESTAPIService(uriPrefix, testHandlers); + cloneRestAPI.initialize(appEvents); + + await cloneRestAPI.start(); + assert.isTrue(cloneRestAPI.isRunning()); + assert.isTrue(restAPI.isRunning()); + + const restOp = await sendRequest({ path: '/regular', method: 'POST' }); + + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(restOp.complete.callCount, 1); + assert.deepStrictEqual(restOp.setBody.callCount, 1); + assert.deepStrictEqual(restOp.setContentType.callCount, 1); + assert.deepStrictEqual(restOp.setStatusCode.callCount, 1); + }); + + it('should not process request when stopped', async () => { + await restAPI.stop(); + assert.isFalse(restAPI.isRunning()); + + const restOp = await sendRequest({ path: '/regular', method: 'POST' }); + assert.deepStrictEqual(restOp.statusCode, HTTP_CODES.SERVICE_UNAVAILABLE); + }); + + it('should process multiple request at a time', async () => { + await processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + debug: true + } + }); + + const results = await Promise.all([ + sendRequest({ path: '/regular?code=201&sleep=200', method: 'POST' }), // 0, total 150ms, startTs 0, endTs 200 + testUtil.sleep(30).then(() => sendRequest({ path: '/regular?code=205&sleep=70', method: 'POST' })), // 1, total 100ms, startTs 30, endTs 100 + testUtil.sleep(5).then(() => sendRequest({ path: '/debug?setcode=207&sleep=60', method: 'POST' })), // 2, total 55ms, startTs 5, endTs 65 + testUtil.sleep(10).then(() => sendRequest({ path: '/debug?setcode=208&sleep=30', method: 'POST' })) // 3, total 40ms, startTs 10, endTs 40 + ]); + + assert.sameMembers( + results.map((res) => res.statusCode), + [201, 205, 207, 208] + ); + assert.isAtLeast(results[2].startTs, results[0].startTs); + assert.isAtLeast(results[3].startTs, results[2].startTs); + assert.isAtLeast(results[1].startTs, results[3].startTs); + + assert.isAbove(results[0].elapsed, results[3].elapsed); + assert.isAbove(results[0].elapsed, results[2].elapsed); + assert.isAbove(results[0].elapsed, results[1].elapsed); + + assert.isAbove(results[0].endTs, results[3].endTs); + assert.isAbove(results[0].endTs, results[2].endTs); + assert.isAbove(results[0].endTs, results[1].endTs); + }); + + it('should throw error on attempt to register endpoint when service is stopped', async () => { + const register = []; + + const handler1 = { + destroy: sinon.spy(), + handle: sinon.spy((req, res) => { + res.body = { + message: 'success', + method: req.getMethod() + }; + res.code = 200; + }) + }; + + appEvents.on('restapi.register', (reg) => { + register.push(reg); + }); + + await restAPI.restart(); + assert.lengthOf(register, 1); + + await restAPI.stop(); + assert.isFalse(restAPI.isRunning()); + + assert.throws(() => register[0](['DELETE', 'GET'], '/test', handler1)); + + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + assert.lengthOf(register, 2); + + assert.throws(() => register[0](['DELETE', 'GET'], '/test', handler1)); + assert.doesNotThrow(() => register[1](['DELETE', 'GET'], '/test', handler1)); + }); + + it('should not deregister handler more than once', async () => { + let destroy1; + let destroy2; + let register; + + const handler1 = { + destroy: sinon.spy(), + handle: sinon.spy((req, res) => { + res.body = { + message: 'success', + method: req.getMethod() + }; + res.code = 200; + }) + }; + const handler2 = { + destroy: sinon.spy(), + handle: sinon.spy((req, res) => { + res.body = { + message: 'success2', + method: req.getMethod() + }; + res.code = 200; + }) + }; + + appEvents.on('restapi.register', (reg) => { + register = reg; + }); + + await restAPI.restart(); + assert.isTrue(restAPI.isRunning()); + + let results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.NOT_FOUND); + + destroy1 = register(['DELETE', 'GET'], '/test', handler1); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 2); + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[0].body, { message: 'success', method: 'DELETE' }); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success', method: 'GET' }); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + + destroy2 = register(['GET', 'POST'], '/test', handler2); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 3); + assert.deepStrictEqual(handler2.handle.callCount, 2); + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[0].body, { message: 'success', method: 'DELETE' }); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success2', method: 'GET' }); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[2].body, { message: 'success2', method: 'POST' }); + + await destroy1(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 3); + assert.deepStrictEqual(handler2.handle.callCount, 4); + assert.deepStrictEqual(handler1.destroy.callCount, 2, 'should call .destroy twice for every registered HTTP method'); + assert.sameDeepMembers(handler1.destroy.args, [ + ['DELETE', '/service/test'], + ['GET', '/service/test'] + ]); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success2', method: 'GET' }); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[2].body, { message: 'success2', method: 'POST' }); + + await destroy1(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 3); + assert.deepStrictEqual(handler2.handle.callCount, 6); + assert.deepStrictEqual(handler1.destroy.callCount, 2, 'should not call .destroy once handler removed'); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.METHOD_NOT_ALLOWED); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success2', method: 'GET' }); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[2].body, { message: 'success2', method: 'POST' }); + + await destroy2(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 3); + assert.deepStrictEqual(handler2.handle.callCount, 6); + assert.deepStrictEqual(handler1.destroy.callCount, 2, 'should not call .destroy once handler removed'); + assert.deepStrictEqual(handler2.destroy.callCount, 2, 'should call .destroy twice for every registered HTTP method'); + assert.sameDeepMembers(handler2.destroy.args, [ + ['GET', '/service/test'], + ['POST', '/service/test'] + ]); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.NOT_FOUND); + await destroy2(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }), + sendRequest({ path: '/test', method: 'POST' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 3); + assert.deepStrictEqual(handler2.handle.callCount, 6); + assert.deepStrictEqual(handler1.destroy.callCount, 2, 'should not call .destroy once handler removed'); + assert.deepStrictEqual(handler2.destroy.callCount, 2, 'should not call .destroy once handler removed'); + + handler1.handle.resetHistory(); + handler2.handle.resetHistory(); + handler1.destroy.resetHistory(); + handler2.destroy.resetHistory(); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[2].statusCode, HTTP_CODES.NOT_FOUND); + + destroy1 = register(['DELETE', 'GET'], '/test', handler1); + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 2); + assert.deepStrictEqual(handler2.handle.callCount, 0); + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[0].body, { message: 'success', method: 'DELETE' }); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success', method: 'GET' }); + + destroy2 = register(['DELETE', 'GET'], '/test', handler2); + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 2); + assert.deepStrictEqual(handler2.handle.callCount, 2); + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[0].body, { message: 'success2', method: 'DELETE' }); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.OK); + assert.deepStrictEqual(results[1].body, { message: 'success2', method: 'GET' }); + + await destroy2(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 2); + assert.deepStrictEqual(handler2.handle.callCount, 2); + assert.deepStrictEqual(handler2.destroy.callCount, 2, 'should call .destroy twice for every registered HTTP method'); + assert.sameDeepMembers(handler2.destroy.args, [ + ['DELETE', '/service/test'], + ['GET', '/service/test'] + ]); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.NOT_FOUND); + + await destroy1(); + + results = await Promise.all([ + sendRequest({ path: '/test', method: 'DELETE' }), + sendRequest({ path: '/test', method: 'GET' }) + ]); + + assert.deepStrictEqual(handler1.handle.callCount, 2); + assert.deepStrictEqual(handler2.handle.callCount, 2); + assert.deepStrictEqual(handler2.destroy.callCount, 2, 'should not call .destroy more than twice'); + assert.deepStrictEqual(handler1.destroy.callCount, 2, 'should call .destroy twice for every registered HTTP method'); + assert.sameDeepMembers(handler1.destroy.args, [ + ['DELETE', '/service/test'], + ['GET', '/service/test'] + ]); + + assert.deepStrictEqual(results[0].statusCode, HTTP_CODES.NOT_FOUND); + assert.deepStrictEqual(results[1].statusCode, HTTP_CODES.NOT_FOUND); + }); + }); +}); diff --git a/test/unit/restAPI/testHandlers/badInterfaceAPI.js b/test/unit/restAPI/testHandlers/badInterfaceAPI.js new file mode 100644 index 00000000..9fda95b7 --- /dev/null +++ b/test/unit/restAPI/testHandlers/badInterfaceAPI.js @@ -0,0 +1,36 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const handler = Object.freeze({ + handlerRequest() { + return Promise.resolve(); + }, + name: 'BadInterfaceImplemented' +}); + +module.exports = { + initialize(routerEE) { + routerEE.on('restapi.register', (register) => { + try { + register('GET', '/badinterface', handler); + } catch (err) { + // do nothing + } + }); + } +}; diff --git a/test/unit/restAPI/testHandlers/debugHandler.js b/test/unit/restAPI/testHandlers/debugHandler.js new file mode 100644 index 00000000..94303cd4 --- /dev/null +++ b/test/unit/restAPI/testHandlers/debugHandler.js @@ -0,0 +1,38 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const sleep = require('../../shared/util').sleep; + +const handler = Object.freeze({ + handle(req, res) { + if (req.getQueryParams().setcode) { + res.code = parseInt(req.getQueryParams().setcode, 10) || 200; + } + if (req.getQueryParams().sleep) { + return sleep(parseInt(req.getQueryParams().sleep, 10)); + } + return Promise.resolve(); + }, + name: 'DebugEndpoint' +}); + +module.exports = { + initialize(routerEE) { + routerEE.on('restapi.register', (register, config) => config.debug && register('POST', '/debug', handler)); + } +}; diff --git a/test/unit/restAPI/testHandlers/faultyHandler.js b/test/unit/restAPI/testHandlers/faultyHandler.js new file mode 100644 index 00000000..0ba3f2d2 --- /dev/null +++ b/test/unit/restAPI/testHandlers/faultyHandler.js @@ -0,0 +1,36 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const handler = Object.freeze({ + handle(req) { + if (req.getQueryParams().reject) { + return Promise.reject(new Error('expected error - reject')); + } + if (req.getQueryParams().throw) { + throw new Error('expected error - throw'); + } + return Promise.resolve(); + }, + name: 'Faulty' +}); + +module.exports = { + initialize(routerEE) { + routerEE.on('restapi.register', (register) => register('DELETE', '/faulty', handler)); + } +}; diff --git a/test/unit/restAPI/testHandlers/index.js b/test/unit/restAPI/testHandlers/index.js new file mode 100644 index 00000000..ee0c0d87 --- /dev/null +++ b/test/unit/restAPI/testHandlers/index.js @@ -0,0 +1,29 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const badInterface = require('./badInterfaceAPI'); +const debugHandler = require('./debugHandler'); +const faultyHandler = require('./faultyHandler'); +const regular = require('./regularHandler'); + +module.exports = function initialize(routerEE) { + badInterface.initialize(routerEE); + debugHandler.initialize(routerEE); + faultyHandler.initialize(routerEE); + regular.initialize(routerEE); +}; diff --git a/test/unit/restAPI/testHandlers/regularHandler.js b/test/unit/restAPI/testHandlers/regularHandler.js new file mode 100644 index 00000000..e8faf51f --- /dev/null +++ b/test/unit/restAPI/testHandlers/regularHandler.js @@ -0,0 +1,95 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const CONTENT_TYPE = require('../utils').CONTENT_TYPE; +const sleep = require('../../shared/util').sleep; + +const handler = Object.freeze({ + handle(req, res) { + if (!req.getQueryParams().nocode) { + res.code = req.getQueryParams().code ? parseInt(req.getQueryParams().code, 10) : 200; + } + res.contentType = CONTENT_TYPE.JSON; + res.body = { + method: req.getMethod() + }; + if (req.getQueryParams().headers) { + res.body.headers = req.getHeaders(); + } + if (req.getBody()) { + res.body.body = req.getBody(); + } + if (req.getQueryParams().async) { + return Promise.resolve(); + } + if (req.getQueryParams().sleep) { + return sleep(parseInt(req.getQueryParams().sleep, 10)); + } + return undefined; + }, + destroy(method, uri) { + if (method === 'DELETE') { + throw new Error('expected sync destroy error'); + } + if (method === 'GET' && uri.includes('noleadingslash')) { + return Promise.reject(new Error('expected async destroy error')); + } + return Promise.resolve(); + }, + name: 'Regular' +}); + +const debugHandler = Object.freeze({ + handle(req, res) { + res.code = req.getQueryParams().code ? parseInt(req.getQueryParams().code, 10) : 200; + res.body = { + debug: true, + method: req.getMethod() + }; + if (req.getQueryParams().async) { + return Promise.resolve(); + } + return undefined; + }, + name: 'RegularDebug' +}); + +const uriParamsHandler = Object.freeze({ + handle(req, res) { + res.code = 200; + res.contentType = CONTENT_TYPE.JSON; + res.body = { + params: req.getUriParams() + }; + } +}); + +module.exports = { + initialize(routerEE) { + routerEE.on('restapi.register', (register, config) => { + register(['GET', 'DELETE'], '/regular', handler); + register('POST', '/regular', handler); + register('GET', 'noleadingslash', handler); + register('GET', '/regular/another', handler); + // eslint-disable-next-line no-unused-expressions + config.debug && register('POST', '/regular/debug', debugHandler); + register('GET', '/regular/debug', debugHandler); + register('GET', '/regular/:required1/subpath/:required2/:optional?', uriParamsHandler); + }); + } +}; diff --git a/test/unit/restAPI/utils.js b/test/unit/restAPI/utils.js new file mode 100644 index 00000000..f786ceef --- /dev/null +++ b/test/unit/restAPI/utils.js @@ -0,0 +1,125 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const querystring = require('querystring'); + +const testUtil = require('../shared/util'); + +const CONTENT_TYPE = Object.freeze({ + JSON: 'application/json' +}); + +const HTTP_CODES = Object.freeze({ + ACCEPTED: 202, + CREATED: 201, + INTERNAL_SERVER_ERROR: 500, + OK: 200, + METHOD_NOT_ALLOWED: 405, + NOT_FOUND: 404, + SERVICE_UNAVAILABLE: 503, + UNPROCESSABLE_ENTITY: 422, + UNSUPPORTED_MEDIA_TYPE: 415 +}); + +const HTTP_METHODS = Object.freeze({ + DELETE: 'DELETE', + GET: 'GET', + POST: 'POST' +}); + +const TELEMETRY_URI_PREFIX = '/mgmt/shared/telemetry/'; +const TELEMETRY_URI = `http://localhost:8100${TELEMETRY_URI_PREFIX}`; + +function buildRequest({ + body = '', + contentType = CONTENT_TYPE.JSON, + headers = null, + method = HTTP_METHODS.GET, + params = {}, + path = '/', + rootURI = TELEMETRY_URI +}) { + const query = querystring.stringify(params); + if (query) { + path = `${path}?${query}`; + } + + if (path.startsWith('/') && rootURI.endsWith('/')) { + path = path.slice(1); + } + + path = `${rootURI}${path}`; + + const op = new testUtil.MockRestOperation(); + op.method = method.toUpperCase(); + op.headers = headers; + op.parseAndSetURI(path); + + if (body) { + op.body = body; + op.contentType = contentType; + } + + return op; +} + +function processDeclaration(configWorker, appEvents, decl, waitFor = true) { + return Promise.all([ + configWorker.processDeclaration(decl), + waitFor ? appEvents.waitFor('restapi.config.applied') : Promise.resolve() + ]); +} + +function sendRequest(restWorker, request) { + if (request.method === HTTP_METHODS.DELETE) { + return restWorker.onDelete(request); + } + if (request.method === HTTP_METHODS.GET) { + return restWorker.onGet(request); + } + if (request.method === HTTP_METHODS.POST) { + return restWorker.onPost(request); + } + throw new Error(`Unsupported HTTP method '${request.method}'`); +} + +function waitRequestComplete(restWorker, request) { + request.startTs = Date.now(); + setImmediate(() => sendRequest(restWorker, request)); + return new Promise((resolve) => { + request.complete.callsFake(() => { + request.endTs = Date.now(); + request.elapsed = request.endTs - request.startTs; + resolve(); + }); + }) + .then(() => request); +} + +module.exports = { + CONTENT_TYPE, + HTTP_CODES, + HTTP_METHODS, + TELEMETRY_URI, + TELEMETRY_URI_PREFIX, + + buildRequest, + processDeclaration, + sendRequest, + waitRequestComplete +}; diff --git a/test/unit/restWorkerTests.js b/test/unit/restWorkerTests.js index 19666e31..1e17708a 100644 --- a/test/unit/restWorkerTests.js +++ b/test/unit/restWorkerTests.js @@ -22,21 +22,22 @@ const moduleCache = require('./shared/restoreCache')(); const sinon = require('sinon'); const assert = require('./shared/assert'); +const restUtils = require('./restAPI/utils'); const sourceCode = require('./shared/sourceCode'); const stubs = require('./shared/stubs'); const testUtil = require('./shared/util'); -const configWorker = sourceCode('src/lib/config'); -const deviceUtil = sourceCode('src/lib/utils/device'); const RestWorker = sourceCode('src/nodejs/restWorker'); -const requestRouter = sourceCode('src/lib/requestHandlers/router'); +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); moduleCache.remember(); describe('restWorker', () => { + let appEvents; + let configWorker; let coreStub; + let deviceVersionStub; let restWorker; - let gatherHostDeviceInfoStub; const baseState = { _data_: { @@ -51,31 +52,43 @@ describe('restWorker', () => { before(() => { moduleCache.restore(); - RestWorker.prototype.loadState = function (first, cb) { + RestWorker.prototype.loadState = sinon.stub(); + RestWorker.prototype.saveState = sinon.stub(); + RestWorker.prototype.loadState.callsFake((first, cb) => { cb(null, testUtil.deepCopy(baseState)); - }; - RestWorker.prototype.saveState = function (first, state, cb) { + }); + RestWorker.prototype.saveState.callsFake((first, state, cb) => { cb(null); - }; - - // remove all existing listeners as consumers, systemPoller and - // prev instances of RestWorker - configWorker.removeAllListeners(); + }); }); beforeEach(() => { - coreStub = stubs.default.coreStub(); + coreStub = stubs.default.coreStub({ + appEvents: true, + configWorker: true, + tracer: true, + utilMisc: true + }); coreStub.utilMisc.generateUuid.numbersOnly = false; + configWorker = coreStub.configWorker.configWorker; + configWorker.removeAllListeners(); + + coreStub.localhostBigIp.mockDeviceType(); + deviceVersionStub = coreStub.localhostBigIp.mockDeviceVersion(); + + appEvents = coreStub.appEvents.appEvents; + restWorker = new RestWorker(); - gatherHostDeviceInfoStub = sinon.stub(deviceUtil, 'gatherHostDeviceInfo'); - gatherHostDeviceInfoStub.resolves(); + restWorker.initialize(appEvents); }); - afterEach(() => (restWorker.activityRecorder - ? restWorker.activityRecorder.stop() - : Promise.resolve()) - .then(() => sinon.restore())); + afterEach(async () => { + await restWorker.tsDestroy(); + + testUtil.nockCleanup(); + sinon.restore(); + }); describe('constructor', () => { it('should set WORKER_URI_PATH to shared/telemetry', () => { @@ -106,81 +119,341 @@ describe('restWorker', () => { assert.ok(/onStartCompleted error/.test(fakeFailure.args[0][0])); }); - it('should call failure callback if unable to start application when promise chain failed', () => { + it('should call failure callback if unable to start application when promise chain failed', async () => { const loadConfigStub = sinon.stub(configWorker, 'load'); loadConfigStub.rejects(new Error('loadConfig error')); - return new Promise((resolve, reject) => { + + await new Promise((resolve, reject) => { restWorker.onStartCompleted( () => reject(new Error('should not call success callback')), () => resolve() ); - }) - .then(() => { - assert.notStrictEqual(loadConfigStub.callCount, 0); - }); + }); + + assert.notStrictEqual(loadConfigStub.callCount, 0); }); - it('should gather host device info', () => new Promise((resolve, reject) => { - restWorker.onStartCompleted(resolve, (msg) => reject(new Error(msg || 'no message provided'))); - }) - .then(() => new Promise((resolve, reject) => { - setTimeout(() => { - try { - // should be 1 because gatherHostDeviceInfo resolves on first attempt - assert.strictEqual(gatherHostDeviceInfoStub.callCount, 1); - resolve(); - } catch (err) { - reject(err); - } - }, 200); - }))); + it('should gather host device info', async () => { + await new Promise((resolve, reject) => { + restWorker.onStartCompleted(resolve, (msg) => reject(new Error(msg || 'no message provided'))); + }); + + await testUtil.sleep(200); + assert.strictEqual(deviceVersionStub.stub.callCount, 1); + }); - it('should not fail when unable to gather host device info', () => { - gatherHostDeviceInfoStub.rejects(new Error('expected error')); - return new Promise((resolve, reject) => { + it('should not fail when unable to gather host device info', async () => { + deviceVersionStub.stub.returns([404, '', 'Not Found']); + + await new Promise((resolve, reject) => { restWorker.onStartCompleted(resolve, (msg) => reject(new Error(msg || 'no message provided'))); }); }); - it('should start activity recorder', () => { - coreStub.persistentStorage.loadData = { config: { raw: { class: 'Telemetry_Test' } } }; - return new Promise((resolve, reject) => { + it('should start activity recorder', async () => { + baseState._data_ = { config: { raw: { class: 'Telemetry_Test' } } }; + + await new Promise((resolve, reject) => { restWorker.onStartCompleted(resolve, reject); - }) - .then(() => testUtil.sleep(100)) - .then(() => coreStub.tracer.waitForData()) - .then(() => { - const data = coreStub.tracer.data[declarationTracerFile]; - assert.lengthOf(data, 4, 'should write 4 events'); - assert.sameDeepMembers( - data.map((d) => d.data.event), - ['received', 'received', 'validationSucceed', 'validationFailed'] - ); - }); + }); + await testUtil.sleep(100); + await coreStub.tracer.waitForData(); + + const data = coreStub.tracer.data[declarationTracerFile]; + assert.lengthOf(data, 4, 'should write 4 events'); + assert.sameDeepMembers( + data.map((d) => d.data.event), + ['config.received', 'config.received', 'config.validationSucceed', 'config.validationFailed'] + ); }); }); describe('requests processing', () => { - let requestsProcessStub; + let ee; + let requestHandler; + let unregHandler; + + function sendRequest() { + return restUtils.waitRequestComplete( + restWorker, + restUtils.buildRequest.apply(restUtils, arguments) + ); + } + beforeEach(() => { - requestsProcessStub = sinon.stub(requestRouter, 'processRestOperation'); - requestsProcessStub.callsFake(); + unregHandler = null; + ee = new SafeEventEmitter(); + + appEvents.register(ee, 'example', [ + 'requestHandler.created' + ]); + + ee.emit('requestHandler.created', async (restOp) => { + await requestHandler(restOp); + }, (unreg) => { + unregHandler = unreg; + }); }); - const httpMethodsMapping = { - DELETE: 'onDelete', - GET: 'onGet', - POST: 'onPost' - }; - Object.keys(httpMethodsMapping).forEach((httpMethod) => { - it(`should pass ${httpMethod} request to requests router`, () => new Promise((resolve, reject) => { - restWorker.onStartCompleted(resolve, (msg) => reject(new Error(msg || 'no message provided'))); - }) - .then(() => { - assert.notOk(requestsProcessStub.called, 'should not be called yet'); - restWorker[httpMethodsMapping[httpMethod]]({}); - assert.ok(requestsProcessStub.called, 'should pass request to router'); - })); + afterEach(() => unregHandler()); + + [ + 'DELETE', + 'GET', + 'POST' + ].forEach((method) => describe(method, () => { + it('should response with 503 on request when no request handlers', async () => { + unregHandler(); + const restOp = await sendRequest({ method }); + + assert.deepStrictEqual(restOp.statusCode, 503); + assert.deepStrictEqual(restOp.contentType, 'application/json'); + assert.deepStrictEqual(restOp.body, { + code: 503, + message: 'Service Unavailable' + }); + }); + + it('should forward request to handlers', async () => { + requestHandler = sinon.spy(async (restOp) => { + assert.deepStrictEqual(restOp.getMethod(), method); + restOp.setStatusCode(200); + restOp.setContentType('test'); + restOp.setBody({ data: true }); + restOp.complete(); + }); + const restOp = await sendRequest({ method }); + + assert.deepStrictEqual(requestHandler.callCount, 1); + assert.deepStrictEqual(restOp.statusCode, 200); + assert.deepStrictEqual(restOp.contentType, 'test'); + assert.deepStrictEqual(restOp.body, { + data: true + }); + }); + + it('should return 500 on error', async () => { + requestHandler = sinon.spy(async () => { + throw new Error('test'); + }); + const restOp = await sendRequest({ method }); + + assert.deepStrictEqual(requestHandler.callCount, 1); + assert.deepStrictEqual(restOp.statusCode, 500); + assert.deepStrictEqual(restOp.contentType, 'application/json'); + assert.deepStrictEqual(restOp.body, { + code: 500, + message: 'Internal Server Error' + }); + }); + + it('should process multiple requests at a time', async () => { + let code = 200; + requestHandler = sinon.spy(async (restOp) => { + await testUtil.sleep(10); + + if (code === 203) { + throw new Error('expected error'); + } + + restOp.setStatusCode(code); + restOp.setContentType(`test${code}`); + restOp.setBody({ code, path: restOp.getUri().pathname }); + restOp.complete(); + + code += 1; + }); + + const results = await Promise.all([ + sendRequest({ path: '/1', method }), + sendRequest({ path: '/2', method }), + sendRequest({ path: '/3', method }), + sendRequest({ path: '/4', method }) + ]); + + assert.deepStrictEqual(requestHandler.callCount, 4); + assert.sameMembers( + results.map((r) => r.statusCode), + [200, 201, 202, 500] + ); + assert.sameMembers( + results.map((r) => r.contentType), + ['test200', 'test201', 'test202', 'application/json'] + ); + assert.sameDeepMembers( + results.map((r) => r.body), + [ + { code: 200, path: '/mgmt/shared/telemetry/1' }, + { code: 201, path: '/mgmt/shared/telemetry/2' }, + { code: 202, path: '/mgmt/shared/telemetry/3' }, + { code: 500, message: 'Internal Server Error' } + ] + ); + }); + })); + + it('should process multiple requests at a time', async () => { + let code = 200; + + requestHandler = sinon.spy(async (restOp) => { + await testUtil.sleep(10); + + if (code === 203) { + throw new Error('expected error'); + } + + restOp.setStatusCode(code); + restOp.setContentType(`test${code}`); + restOp.setBody({ + code, + method: restOp.getMethod(), + path: restOp.getUri().pathname + }); + restOp.complete(); + + code += 1; + }); + + const results = await Promise.all([ + sendRequest({ path: '/1', method: 'DELETE' }), + sendRequest({ path: '/2', method: 'GET' }), + sendRequest({ path: '/3', method: 'POST' }), + sendRequest({ path: '/4', method: 'GET' }) + ]); + + assert.deepStrictEqual(requestHandler.callCount, 4); + assert.sameMembers( + results.map((r) => r.statusCode), + [200, 201, 202, 500] + ); + assert.sameMembers( + results.map((r) => r.contentType), + ['test200', 'test201', 'test202', 'application/json'] + ); + assert.sameDeepMembers( + results.map((r) => r.body), + [ + { code: 200, method: 'DELETE', path: '/mgmt/shared/telemetry/1' }, + { code: 201, method: 'GET', path: '/mgmt/shared/telemetry/2' }, + { code: 202, method: 'POST', path: '/mgmt/shared/telemetry/3' }, + { code: 500, message: 'Internal Server Error' } + ] + ); + }); + + it('should not rotate request handlers when failed', async () => { + const primaryHandler = sinon.spy(async (restOp) => { + if (primaryHandler.callCount === 1) { + throw new Error('expected error'); + } + + restOp.setStatusCode(200); + restOp.setContentType('test200'); + restOp.setBody({ + code: 200, + method: restOp.getMethod(), + path: restOp.getUri().pathname + }); + restOp.complete(); + }); + ee.emit('requestHandler.created', primaryHandler); + + requestHandler = sinon.spy(async (restOp) => { + restOp.setStatusCode(404); + restOp.setContentType('test404'); + restOp.setBody({ + code: 404, + method: restOp.getMethod(), + path: restOp.getUri().pathname + }); + restOp.complete(); + }); + + let restOp = await sendRequest({ path: '/1', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, 500); + assert.deepStrictEqual(restOp.contentType, 'application/json'); + assert.deepStrictEqual(restOp.body, { + code: 500, + message: 'Internal Server Error' + }); + + assert.deepStrictEqual(primaryHandler.callCount, 1); + assert.deepStrictEqual(requestHandler.callCount, 0); + + restOp = await sendRequest({ path: '/1', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, 200); + assert.deepStrictEqual(restOp.contentType, 'test200'); + assert.deepStrictEqual(restOp.body, { + code: 200, + method: 'DELETE', + path: '/mgmt/shared/telemetry/1' + }); + }); + + it('should fallback to next handler from the list', async () => { + let unregPrimary = null; + const primaryHandler = sinon.spy(async (restOp) => { + restOp.setStatusCode(200); + restOp.setContentType('test200'); + restOp.setBody({ + code: 200, + method: restOp.getMethod(), + path: restOp.getUri().pathname + }); + restOp.complete(); + }); + ee.emit('requestHandler.created', primaryHandler, (unreg) => { + unregPrimary = unreg; + }); + + requestHandler = sinon.spy(async (restOp) => { + restOp.setStatusCode(404); + restOp.setContentType('test404'); + restOp.setBody({ + code: 404, + method: restOp.getMethod(), + path: restOp.getUri().pathname + }); + restOp.complete(); + }); + + let restOp = await sendRequest({ path: '/1', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, 200); + assert.deepStrictEqual(restOp.contentType, 'test200'); + assert.deepStrictEqual(restOp.body, { + code: 200, + method: 'DELETE', + path: '/mgmt/shared/telemetry/1' + }); + + assert.deepStrictEqual(primaryHandler.callCount, 1); + assert.deepStrictEqual(requestHandler.callCount, 0); + + unregPrimary(); + + restOp = await sendRequest({ path: '/1', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, 404); + assert.deepStrictEqual(restOp.contentType, 'test404'); + assert.deepStrictEqual(restOp.body, { + code: 404, + method: 'DELETE', + path: '/mgmt/shared/telemetry/1' + }); + + unregHandler(); + + restOp = await sendRequest({ path: '/1', method: 'DELETE' }); + + assert.deepStrictEqual(restOp.statusCode, 503); + assert.deepStrictEqual(restOp.contentType, 'application/json'); + assert.deepStrictEqual(restOp.body, { + code: 503, + message: 'Service Unavailable' + }); }); }); }); diff --git a/test/unit/runtimeConfig/runtimeConfigTests.js b/test/unit/runtimeConfig/runtimeConfigTests.js index afac164c..bd36cf4f 100644 --- a/test/unit/runtimeConfig/runtimeConfigTests.js +++ b/test/unit/runtimeConfig/runtimeConfigTests.js @@ -19,1150 +19,872 @@ /* eslint-disable import/order, no-template-curly-in-string, prefer-regex-literals */ const moduleCache = require('../shared/restoreCache')(); -const fs = require('fs'); -const memfs = require('memfs'); -const nock = require('nock'); +const nodeFS = require('fs'); const pathUtil = require('path'); const sinon = require('sinon'); const assert = require('../shared/assert'); +const rcUtils = require('./shared'); const sourceCode = require('../shared/sourceCode'); const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); -const configWorker = sourceCode('src/lib/config'); -const deviceUtil = sourceCode('src/lib/utils/device'); -const persistentStorage = sourceCode('src/lib/persistentStorage'); const RuntimeConfig = sourceCode('src/lib/runtimeConfig'); const updater = sourceCode('src/lib/runtimeConfig/updater'); +const utilMisc = sourceCode('src/lib/utils/misc'); moduleCache.remember(); -describe('Resource Monitor / Resource Monitor', () => { - const RESTNODE_SCRIPT_FNAME = '/etc/bigstart/scripts/restnoded'; - const UPDATER_DIR = pathUtil.join(__dirname, '../../../src/lib/runtimeConfig'); - const UPDATER_LOGS = pathUtil.join(UPDATER_DIR, 'logs.txt'); - +describe('Runtime Config / Runtime Config', () => { let clock; + let configWorker; let coreStub; + let failUpdaterCmd; + let failRestartCmd; + let dacliMock; let isBashEnabled; let processExitStub; let remoteCmds; - let remoteCmbStub; let restApiSysDB; let runtimeConfig; - let virtualFS; - let volume; + let updaterHook; before(() => { moduleCache.restore(); - - volume = new memfs.Volume(); - virtualFS = memfs.createFsFromVolume(volume); }); - beforeEach(() => { + beforeEach(async () => { clock = stubs.clock(); - remoteCmds = []; + failUpdaterCmd = false; + failRestartCmd = false; + + coreStub = stubs.default.coreStub(); + const shellStatusMock = coreStub.localhostBigIp.mockIsShellEnabled( + true, { optionally: true, replyTimes: Infinity } + ); + + isBashEnabled = true; + restApiSysDB = () => ({ value: `${!isBashEnabled}` }); + shellStatusMock.stub.callsFake(() => [200, restApiSysDB()]); - remoteCmbStub = sinon.stub(deviceUtil.DeviceAsyncCLI.prototype, 'execute'); - remoteCmbStub.callsFake((cmd) => { - remoteCmds.push(cmd); - if (cmd.indexOf('updater') !== -1) { - updater.main(virtualFS); + remoteCmds = []; + dacliMock = coreStub.localhostBigIp.mockDACLI(/restnoded|updater/, { optionally: true, replyTimes: Infinity }); + + let updaterTaskID; + let restartTaskID; + updaterHook = sinon.stub(); + dacliMock.createTask.stub.callsFake((_taskId, reqBody) => { + remoteCmds.push(reqBody.utilCmdArgs); + if (reqBody.utilCmdArgs.includes('updater')) { + updaterTaskID = _taskId; } - return Promise.resolve(); + if (reqBody.utilCmdArgs.includes('restnoded')) { + restartTaskID = _taskId; + } + return [200, { _taskId }]; + }); + dacliMock.pollTaskResult.stub.callsFake(async (taskID) => { + let state = 'COMPLETED'; + if (taskID === updaterTaskID) { + await updaterHook(); + } + if ((taskID === updaterTaskID && failUpdaterCmd) || (taskID === restartTaskID && failRestartCmd)) { + state = 'FAILED'; + } + if (taskID === updaterTaskID && !failUpdaterCmd) { + updater.main(utilMisc.fs); + } + return [200, { _taskState: state }]; }); processExitStub = sinon.stub(process, 'exit'); processExitStub.callsFake(() => {}); - volume.reset(); + await utilMisc.fs.mkdir(pathUtil.dirname(rcUtils.RESTNODE_SCRIPT_FNAME)); + await utilMisc.fs.mkdir(rcUtils.UPDATER_DIR, { recursive: true }); + await utilMisc.fs.writeFile( + rcUtils.RESTNODE_SCRIPT_FNAME, + nodeFS.readFileSync(pathUtil.join(__dirname, 'bigstart_restnode')) + ); + + configWorker = coreStub.configWorker.configWorker; - volume.mkdirSync(pathUtil.dirname(RESTNODE_SCRIPT_FNAME), { recursive: true }); - volume.mkdirSync(UPDATER_DIR, { recursive: true }); + runtimeConfig = new RuntimeConfig(); + runtimeConfig.initialize(coreStub.appEvents.appEvents); - virtualFS.writeFileSync( - RESTNODE_SCRIPT_FNAME, - fs.readFileSync(pathUtil.join(__dirname, 'bigstart_restnode')) + rcUtils.init({ virtFS: utilMisc.fs }); + + await coreStub.startServices(); + await configWorker.cleanup(); + }); + + afterEach(async () => { + if (clock) { + clock.stub.restore(); + } + + await runtimeConfig.destroy(); + await coreStub.destroyServices(); + + rcUtils.destroy(); + testUtil.nockCleanup(); + sinon.restore(); + }); + + function processDeclaration(decl, sleepOpts) { + return Promise.all([ + configWorker.processDeclaration({ + class: 'Telemetry', + controls: { + class: 'Controls', + runtime: decl + } + }), + sleepOpts !== false + ? clock.clockForward( + (sleepOpts || {}).time || 3000, + Object.assign({ promisify: true, delay: 1, repeat: 100 }, sleepOpts || {}) + ) + : Promise.resolve(), + coreStub.appEvents.appEvents.waitFor('runtimecfg.config.applied') + ]); + } + + it('should process default runtime configuration', async () => { + await runtimeConfig.start(); + + coreStub.logger.removeAllMessages(); + await processDeclaration(); + + assert.includeMatch( + coreStub.logger.messages.all, + /No changes found between running configuration and the new one/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() ); + assert.isEmpty(remoteCmds); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - coreStub = stubs.default.coreStub({}, { logger: { ignoreLevelChange: false } }); - coreStub.persistentStorage.loadData = { config: { } }; + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); - runtimeConfig = new RuntimeConfig(virtualFS); + it('should process runtime configuration with default values', async () => { + await runtimeConfig.start(); - isBashEnabled = true; - restApiSysDB = () => [200, { value: isBashEnabled }]; - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', - method: 'get', - response: () => restApiSysDB(), - options: { - times: 100 - } - }]); + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: rcUtils.GC_DEFAULT, + maxHeapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT_SEC + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /No changes found between running configuration and the new one/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + assert.isEmpty(remoteCmds); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - return configWorker.cleanup() - .then(() => persistentStorage.persistentStorage.load()) - .then(() => runtimeConfig.initialize({ configMgr: configWorker })); + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); }); - afterEach(() => runtimeConfig.destroy() - .then(() => { - nock.cleanAll(); - sinon.restore(); - })); + it('should do nothing when unable to read configuration from the script', async () => { + await runtimeConfig.start(); - function deleteScript() { - return virtualFS.unlinkSync(RESTNODE_SCRIPT_FNAME); - } + rcUtils.deleteScript(); + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: rcUtils.GC_DEFAULT, + maxHeapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT_SEC + }); - function getScript() { - return virtualFS.readFileSync(RESTNODE_SCRIPT_FNAME).toString(); - } + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to read configuration from the startup script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.throws(() => rcUtils.getScript()); + assert.isEmpty(remoteCmds); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - function getTaskID() { - return JSON.parse(virtualFS.readFileSync(pathUtil.join(UPDATER_DIR, 'config.json'))).id; - } + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); - function processDeclaration(decl) { - return configWorker.processDeclaration({ - class: 'Telemetry', - controls: { - class: 'Controls', - runtime: decl - } + it('should do nothing when bash disabled', async () => { + await runtimeConfig.start(); + + isBashEnabled = false; + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 }); - } - describe('.initialize()', () => { - it('should log message when unable to subscribe to config updates', () => { - const rc = new RuntimeConfig(); - rc.initialize({}); + assert.includeMatch( + coreStub.logger.messages.all, + /Shell not available, unable to proceed with task execution/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + assert.isEmpty(remoteCmds); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should fail when unable to run updater command', async () => { + await runtimeConfig.start(); - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to subscribe to configuration updates/ - ); + failUpdaterCmd = true; + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 }); + + assert.includeMatch( + coreStub.logger.messages.all, + /no logs available/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Attempt to update the runtime configuration failed! See logs for more details/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task failed/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + assert.lengthOf(remoteCmds, 2); + assert.includeMatch(remoteCmds, /updater/); + + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.lengthOf(remoteCmds, 2); + assert.includeMatch(remoteCmds, /updater/); + assert.isEmpty(coreStub.logger.messages.all); }); - it('should process default runtime configuration', () => runtimeConfig.start() - .then(() => { - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration(), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /No changes found between running configuration and the new one/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should process runtime configuration with default values', () => runtimeConfig.start() - .then(() => { - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: false, - maxHeapSize: 1400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /No changes found between running configuration and the new one/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should do nothing when unable to read configuration from the script', () => runtimeConfig.start() - .then(() => { - deleteScript(); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: false, - maxHeapSize: 1400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to read configuration from the startup script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.throws(() => getScript()); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should do nothing when bash disabled', () => runtimeConfig.start() - .then(() => { - isBashEnabled = false; - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Shell not available, unable to proceed with task execution/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should fail when unable to run remote command', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.rejects(new Error('expected error')); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /no logs available/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Attempt to update the runtime configuration failed! See logs for more details/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task failed/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should fail when changes were not applied to the startup script', () => runtimeConfig.start() - .then(() => { - sinon.stub(updater, 'main').callsFake(() => {}); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Configuration was not applied to the script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task failed/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.includeMatch(remoteCmds, `${process.argv[0]} ${UPDATER_DIR}/updater.js`); - remoteCmds = []; - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should finish task once restart scheduled', () => runtimeConfig.start() - .then(() => { - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /New configuration was successfully applied to the startup script! Scheduling service restart in 1 min/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Restarting service to apply new changes for the runtime configuraiton/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.includeMatch(remoteCmds, `${process.argv[0]} ${UPDATER_DIR}/updater.js`); - assert.includeMatch(remoteCmds, 'bigstart restart restnoded'); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should force restart when unable to schedule restart via remote cmd', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.onSecondCall().rejects(new Error('expected error')); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /New configuration was successfully applied to the startup script! Scheduling service restart in 1 min/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Restarting service to apply new changes for the runtime configuraiton/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to restart service via bigstart/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to restart service gracefully/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual(processExitStub.callCount, 1); - - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should report that bash disabled when unable check its status', () => runtimeConfig.start() - .then(() => { - restApiSysDB = () => [ - 404, - 'Not Found' - ]; - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Shell not available, unable to proceed with task execution/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.isEmpty(remoteCmds); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should not fail when unable to read script configuration once remote cmd executed', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.onFirstCall().callsFake(() => { - virtualFS.writeFileSync(RESTNODE_SCRIPT_FNAME, 'something'); - return Promise.resolve(); - }); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Trying to execute "updater" script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to read configuration from the startup script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - assert.deepStrictEqual( - getScript(), - 'something' - ); - remoteCmds = []; - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should stop task once service stopped', () => runtimeConfig.start() - .then(() => { - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(5, { promisify: true, delay: 1, repeat: 50 }), - testUtil.waitTill(() => { - try { - assert.includeMatch( - coreStub.logger.messages.all, - /New configuration was successfully applied to the startup script! Scheduling service restart/ - ); - return true; - } catch (err) { - return false; - } - }, 1) - ]); - }) - .then(() => Promise.all([ + it('should fail when changes were not applied to the startup script', async () => { + await runtimeConfig.start(); + + sinon.stub(updater, 'main').callsFake(() => {}); + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Configuration was not applied to the script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task failed/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + assert.includeMatch(remoteCmds, `${process.argv[0]} ${rcUtils.UPDATER_DIR}/updater.js`); + remoteCmds = []; + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should finish task once restart scheduled', async () => { + await runtimeConfig.start(); + + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /New configuration was successfully applied to the startup script! Scheduling service restart in 1 min/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Restarting service to apply new changes for the runtime configuraiton/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.includeMatch(remoteCmds, `${process.argv[0]} ${rcUtils.UPDATER_DIR}/updater.js`); + assert.includeMatch(remoteCmds, 'bigstart restart restnoded'); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should force restart when unable to schedule restart via remote cmd', async () => { + await runtimeConfig.start(); + + failRestartCmd = true; + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /New configuration was successfully applied to the startup script! Scheduling service restart in 1 min/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Restarting service to apply new changes for the runtime configuraiton/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to restart service via bigstart/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to restart service gracefully/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual(processExitStub.callCount, 1); + + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should report that bash disabled when unable check its status', async () => { + await runtimeConfig.start(); + + restApiSysDB = () => [ + 404, + 'Not Found' + ]; + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Shell not available, unable to proceed with task execution/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + assert.isEmpty(remoteCmds); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should not fail when unable to read script configuration once remote cmd executed', async () => { + await runtimeConfig.start(); + + updaterHook.callsFake(() => utilMisc.fs.writeFileSync(rcUtils.RESTNODE_SCRIPT_FNAME, 'something')); + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Trying to execute "updater" script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to read configuration from the startup script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + 'something' + ); + remoteCmds = []; + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should stop task once service stopped', async () => { + await runtimeConfig.start(); + + coreStub.logger.removeAllMessages(); + await Promise.all([ + processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }, { time: 5, delay: 1, repeat: 50 }), + testUtil.waitTill(() => { + try { + assert.includeMatch( + coreStub.logger.messages.all, + /New configuration was successfully applied to the startup script! Scheduling service restart/ + ); + return true; + } catch (err) { + return false; + } + }, 1) + ]); + + await Promise.all([ runtimeConfig.stop(), clock.clockForward(3000, { promisify: true, delay: 1, repeat: 50 }) - ])) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /New configuration was successfully applied to the startup script! Scheduling service restart/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task stopped/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task emitted event "stopped"/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.includeMatch(remoteCmds, `${process.argv[0]} ${UPDATER_DIR}/updater.js`); - remoteCmds = []; - coreStub.logger.removeAllMessages(); - - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should retry task if possible', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.onFirstCall().callsFake(() => Promise.resolve()); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Configuration was not applied to the script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task failed/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Retrying attempt to update the startup script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /New configuration was successfully applied to the startup script! Scheduling service restart/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.includeMatch(remoteCmds, `${process.argv[0]} ${UPDATER_DIR}/updater.js`); - remoteCmds = []; - coreStub.logger.removeAllMessages(); - - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should not retry task more than once', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.callsFake(() => Promise.resolve()); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Retrying attempt to update the startup script/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task failed/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /No retries left for the failed task/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - coreStub.logger.removeAllMessages(); - return clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); - }) - .then(() => { - assert.isEmpty(remoteCmds); - assert.isEmpty(coreStub.logger.messages.all); - })); - - it('should remove log files before task execution', () => runtimeConfig.start() - .then(() => { - virtualFS.writeFileSync(UPDATER_LOGS, 'existing-data'); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.notIncludeMatch(coreStub.logger.messages.all, /existing-data/); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - - let logs = ''; - try { - logs = virtualFS.readFileSync(UPDATER_LOGS).toString(); - } catch (err) { - // do nothing - } - assert.notIncludeMatch(logs, /existing-data/); - })); + ]); - it('should schedule next task and cancel the current one', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.onSecondCall().callsFake(() => processDeclaration({ + assert.includeMatch( + coreStub.logger.messages.all, + /New configuration was successfully applied to the startup script! Scheduling service restart/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task stopped/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task emitted event "stopped"/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.includeMatch(remoteCmds, `${process.argv[0]} ${rcUtils.UPDATER_DIR}/updater.js`); + remoteCmds = []; + coreStub.logger.removeAllMessages(); + + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should retry task if possible', async () => { + await runtimeConfig.start(); + + updaterHook + .onFirstCall() + .callsFake(() => { + failUpdaterCmd = true; + }) + .onSecondCall() + .callsFake(() => { + failUpdaterCmd = false; + }); + + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Configuration was not applied to the script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task failed/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Retrying attempt to update the startup script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /New configuration was successfully applied to the startup script! Scheduling service restart/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.includeMatch(remoteCmds, `${process.argv[0]} ${rcUtils.UPDATER_DIR}/updater.js`); + remoteCmds = []; + coreStub.logger.removeAllMessages(); + + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.isEmpty(remoteCmds); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should not retry task more than once', async () => { + await runtimeConfig.start(); + + failUpdaterCmd = true; + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Retrying attempt to update the startup script/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task failed/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /No retries left for the failed task/ + ); + + assert.lengthOf(remoteCmds, 2); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeShortScript() + ); + coreStub.logger.removeAllMessages(); + await clock.clockForward(3000, { promisify: true, delay: 10, repeat: 10 }); + + assert.lengthOf(remoteCmds, 2); + assert.isEmpty(coreStub.logger.messages.all); + }); + + it('should remove log files before task execution', async () => { + await runtimeConfig.start(); + + utilMisc.fs.writeFileSync(rcUtils.UPDATER_LOGS, 'existing-data'); + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }); + + assert.notIncludeMatch(coreStub.logger.messages.all, /existing-data/); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + + let logs = ''; + try { + logs = utilMisc.fs.readFileSync(rcUtils.UPDATER_LOGS).toString(); + } catch (err) { + // do nothing + } + assert.notIncludeMatch(logs, /existing-data/); + }); + + it('should schedule next task and cancel the current one', async () => { + await runtimeConfig.start(); + + updaterHook.onFirstCall().callsFake(async () => { + await processDeclaration({ enableGC: false, maxHeapSize: 2500 - }) - .then(() => testUtil.sleep(5000)) - .then(() => Promise.reject(new Error('expected error')))); - - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Task emitted event "stopped"/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task stopped/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2500 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - })); - - it('should schedule next task and cancel the current one and existing "next" task too', () => runtimeConfig.start() - .then(() => { - remoteCmbStub.onSecondCall().callsFake(() => processDeclaration({ + }, false); + await testUtil.sleep(5000); + }); + + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 80 + }, { time: 6000 }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Task emitted event "stopped"/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task stopped/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2500 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + }); + + it('should schedule next task and cancel the current one and existing "next" task too', async () => { + await runtimeConfig.start(); + + updaterHook.onFirstCall().callsFake(async () => { + await processDeclaration({ enableGC: false, maxHeapSize: 2500 - }) - .then(() => processDeclaration({ - enableGC: true, - maxHeapSize: 2600 - })) - .then(() => testUtil.sleep(5000)) - .then(() => Promise.reject(new Error('expected error')))); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Task emitted event "stopped"/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task stopped/ - ); - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2600 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - })); - - it('should not schedule next task when service restart requested', () => runtimeConfig.start() - .then(() => { - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({ - enableGC: true, - maxHeapSize: 2400 - }), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Task done/ - ); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - coreStub.logger.removeAllMessages(); - return Promise.all([ - processDeclaration({}), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.includeMatch( - coreStub.logger.messages.all, - /Unable to schedule next task: the service restart requested already/ - ); - assert.notIncludeMatch( - coreStub.logger.messages.all, - /Task (done|failed|stopped)/ - ); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - })); - - it('should apply configuration', () => runtimeConfig.start() - .then(() => Promise.all([ + }, false); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2600 + }, false); + await testUtil.sleep(5000); + }); + + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 80 + }, { time: 6000 }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Task emitted event "stopped"/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task stopped/ + ); + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2600 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + }); + + it('should not schedule next task when service restart requested', async () => { + await runtimeConfig.start(); + + coreStub.logger.removeAllMessages(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }, { time: 3000 }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Task done/ + ); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + coreStub.logger.removeAllMessages(); + await processDeclaration({}, { time: 3000 }); + + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to schedule next task: the service restart requested already/ + ); + assert.notIncludeMatch( + coreStub.logger.messages.all, + /Task (done|failed|stopped)/ + ); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + }); + + it('should apply configuration', async () => { + await runtimeConfig.start(); + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }, { time: 3000 }); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + // should not apply a new one due restart request + await processDeclaration(undefined, { time: 3000 }); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + await Promise.all([ + runtimeConfig.restart(), + clock.clockForward(6000, { promisify: true, delay: 1, repeat: 10 }) + ]); + + await processDeclaration({ + enableGC: true, + maxHeapSize: 2400, + httpTimeout: 90 + }, { time: 3000 }); + + let script = rcUtils.getScript(); + assert.deepStrictEqual( + script, + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -k 90000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + + script = script.replace('--max-old-space-size', '--max_old_space_size'); + utilMisc.fs.writeFileSync(rcUtils.RESTNODE_SCRIPT_FNAME, script); + + await processDeclaration({ + enableGC: true, + maxHeapSize: 2405, + httpTimeout: 95 + }, { time: 6000 }); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2405 --expose-gc /usr/share/rest/node/src/restnode.js -k 95000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + + await Promise.all([ + runtimeConfig.restart(), + clock.clockForward(6000, { promisify: true, delay: 1, repeat: 10 }) + ]); + + utilMisc.fs.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + rcUtils.getScript().replace('--max-old-space-size', '--max_old_space_size=2060 --max-old-space-size') + ); + + await Promise.all([ processDeclaration({ enableGC: true, - maxHeapSize: 2400 + maxHeapSize: 2405, + httpTimeout: 70 }), clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ])) - .then(() => { - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - // should not apply a new one due restart request - return Promise.all([ - processDeclaration(), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - return Promise.all([ - runtimeConfig.restart(), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 10 }) - ]); - }) - .then(() => Promise.all([ + ]); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max_old_space_size=2060 --max-old-space-size=2405 --expose-gc /usr/share/rest/node/src/restnode.js -k 70000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + + // should not apply due restart request + await Promise.all([ processDeclaration({ enableGC: true, - maxHeapSize: 2400 + maxHeapSize: 2060, + httpTimeout: 80 }), clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ])) - .then(() => { - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node --max_old_space_size=2400 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - return Promise.all([ - processDeclaration({}), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - // should not apply due restart request - return Promise.all([ - processDeclaration({ - enableGC: true - }), - clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) - ]); - }) - .then(() => { - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ` # ID:${getTaskID()}`, - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - })); - - // TODO: - // - test that sumbits multiple declarations (one by one) - // - update RestWorker tests if needed (add new stub) + ]); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max_old_space_size=2060 --max-old-space-size=2405 --expose-gc /usr/share/rest/node/src/restnode.js -k 70000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + await Promise.all([ + runtimeConfig.restart(), + clock.clockForward(6000, { promisify: true, delay: 1, repeat: 10 }) + ]); + + await Promise.all([ + processDeclaration({}), + clock.clockForward(6000, { promisify: true, delay: 1, repeat: 100 }) + ]); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + // should not apply due restart request + await processDeclaration({ + enableGC: true + }, { time: 3000 }); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + }); }); diff --git a/test/unit/runtimeConfig/shared.js b/test/unit/runtimeConfig/shared.js new file mode 100644 index 00000000..e010ef17 --- /dev/null +++ b/test/unit/runtimeConfig/shared.js @@ -0,0 +1,88 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-template-curly-in-string */ + +const pathUtil = require('path'); + +const RESTNODE_SCRIPT_FNAME = '/etc/bigstart/scripts/restnoded'; +const UPDATER_DIR = pathUtil.join(__dirname, '../../../src/lib/runtimeConfig'); +const UPDATER_LOGS = pathUtil.join(UPDATER_DIR, 'logs.txt'); + +let virtualFS = null; + +function getTaskID() { + return JSON.parse(virtualFS.readFileSync(pathUtil.join(UPDATER_DIR, 'config.json'))).id; +} + +function makeScript(execLine, exExecLine, commentBlock) { + execLine = execLine || 'exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1'; + exExecLine = exExecLine || 'exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1'; + + const out = [ + '#!/bin/sh', + '', + 'if [ -f /service/${service}/debug ]; then', + ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + 'else' + ]; + if (typeof commentBlock === 'undefined' || commentBlock) { + out.push( + ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', + ' # To restore original behavior, uncomment the next line and remove the block below.', + ' #', + ` # ${exExecLine}`, + ' #', + ' # The block below should be removed to restore original behavior!', + ` # ID:${getTaskID()}` + ); + } + out.push( + ` ${execLine}`, + 'fi', + '' + ); + return out.join('\n'); +} + +module.exports = { + deleteScript() { + return virtualFS.unlinkSync(RESTNODE_SCRIPT_FNAME); + }, + getScript() { + return virtualFS.readFileSync(RESTNODE_SCRIPT_FNAME).toString(); + }, + destroy() { + virtualFS = null; + }, + init({ virtFS }) { + virtualFS = virtFS; + }, + makeScript, + makeShortScript(execLine, exExecLine) { + return makeScript(execLine, exExecLine, false); + }, + + GC_DEFAULT: false, + HEAP_SIZE_DEFAULT: 1400, + HTTP_TIMEOUT_DEFAULT: 60000, + HTTP_TIMEOUT_DEFAULT_SEC: 60, + RESTNODE_SCRIPT_FNAME, + UPDATER_DIR, + UPDATER_LOGS +}; diff --git a/test/unit/runtimeConfig/updaterTests.js b/test/unit/runtimeConfig/updaterTests.js index cf41f296..3097cecb 100644 --- a/test/unit/runtimeConfig/updaterTests.js +++ b/test/unit/runtimeConfig/updaterTests.js @@ -25,6 +25,7 @@ const pathUtil = require('path'); const sinon = require('sinon'); const assert = require('../shared/assert'); +const rcUtils = require('./shared'); const sourceCode = require('../shared/sourceCode'); const testUtil = require('../shared/util'); @@ -33,10 +34,6 @@ const updater = sourceCode('src/lib/runtimeConfig/updater'); moduleCache.remember(); describe('Runtime Config / Updater', () => { - const RESTNODE_SCRIPT_FNAME = '/etc/bigstart/scripts/restnoded'; - const UPDATER_DIR = pathUtil.join(__dirname, '../../../src/lib/runtimeConfig'); - const UPDATER_LOGS = pathUtil.join(UPDATER_DIR, 'logs.txt'); - let appCtx; let virtualFS; let volume; @@ -48,18 +45,14 @@ describe('Runtime Config / Updater', () => { virtualFS = memfs.createFsFromVolume(volume); }); - afterEach(() => { - sinon.restore(); - }); - beforeEach(() => { volume.reset(); - volume.mkdirSync(pathUtil.dirname(RESTNODE_SCRIPT_FNAME), { recursive: true }); - volume.mkdirSync(UPDATER_DIR, { recursive: true }); + volume.mkdirSync(pathUtil.dirname(rcUtils.RESTNODE_SCRIPT_FNAME), { recursive: true }); + volume.mkdirSync(rcUtils.UPDATER_DIR, { recursive: true }); virtualFS.writeFileSync( - RESTNODE_SCRIPT_FNAME, + rcUtils.RESTNODE_SCRIPT_FNAME, fs.readFileSync(pathUtil.join(__dirname, 'bigstart_restnode')) ); @@ -72,6 +65,13 @@ describe('Runtime Config / Updater', () => { info() {} } }; + + rcUtils.init({ virtFS: virtualFS }); + }); + + afterEach(() => { + rcUtils.destroy(); + sinon.restore(); }); function createTask(data) { @@ -80,9 +80,6 @@ describe('Runtime Config / Updater', () => { function getLogs() { return updater.readLogsFile(appCtx).split('\n'); } - function getScript() { - return virtualFS.readFileSync(RESTNODE_SCRIPT_FNAME).toString(); - } function getCurrentConfig() { return updater.fetchConfigFromScript(appCtx); } @@ -92,8 +89,9 @@ describe('Runtime Config / Updater', () => { assert.deepStrictEqual( updater.enrichScriptConfig({}), { - gcEnabled: false, - heapSize: 1400 + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT } ); }); @@ -102,11 +100,13 @@ describe('Runtime Config / Updater', () => { assert.deepStrictEqual( updater.enrichScriptConfig({ gcEnabled: true, - heapSize: 2000 + heapSize: 2000, + httpTimeout: 120000 }), { gcEnabled: true, - heapSize: 2000 + heapSize: 2000, + httpTimeout: 120000 } ); }); @@ -114,724 +114,707 @@ describe('Runtime Config / Updater', () => { describe('.fetchConfigFromScript()', () => { it('should return null when unable to read config (no file)', () => { - virtualFS.unlinkSync(RESTNODE_SCRIPT_FNAME); + virtualFS.unlinkSync(rcUtils.RESTNODE_SCRIPT_FNAME); assert.isNull(updater.fetchConfigFromScript(appCtx)); }); it('should return null when unable to read config (garbage data)', () => { - virtualFS.writeFileSync(RESTNODE_SCRIPT_FNAME, 'something'); + virtualFS.writeFileSync(rcUtils.RESTNODE_SCRIPT_FNAME, 'something'); assert.isNull(updater.fetchConfigFromScript(appCtx)); }); + + it('should read default config from the script', () => { + virtualFS.writeFileSync(rcUtils.RESTNODE_SCRIPT_FNAME, [ + '#!/bin/sh', + '', + 'if [ -f /service/${service}/debug ]; then', + ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + 'else', + ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', + ' # To restore original behavior, uncomment the next line and remove the block below.', + ' #', + ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + ' #', + ' # The block below should be removed to restore original behavior!', + ' # ID:123', + ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + 'fi', + '' + ].join('\n')); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + }); + }); + + it('should read non-default config from the script', () => { + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max-old-space-size=2048 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: true, + heapSize: 2048, + httpTimeout: 120000 + }); + }); + + it('should read non-default config from the script (example 2)', () => { + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max-old-space-size=2050 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: true, + heapSize: 2050, + httpTimeout: 120000 + }); + }); + + it('should use the furthest match (example 1)', () => { + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max-old-space-size=2050 --max_old_space_size=2040 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: true, + heapSize: 2040, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT + }); + }); + + it('should use the furthest match (example 2)', () => { + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: true, + heapSize: 2030, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT + }); + }); + + it('should use the furthest match (example 3)', () => { + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 --max_old_space_size=2760 --max-old-space-size=2090 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + assert.deepStrictEqual(updater.fetchConfigFromScript(appCtx), { + gcEnabled: true, + heapSize: 2090, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT + }); + }); }); describe('.main()', () => { - it('should do nothing when no config provided', () => { + it('should do nothing when no config provided', async () => { updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - assert.includeMatch(getLogs(), /No config found, nothing to apply to the script/); - }); + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /No config found, nothing to apply to the script/); }); - it('should do nothing when config has no ID', () => { + it('should do nothing when config has no ID', async () => { createTask({ gcEnabled: true }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - assert.includeMatch(getLogs(), /No config found, nothing to apply to the script/); - }); + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /No config found, nothing to apply to the script/); }); - it('should do nothing when unable to read restnode script', () => { - virtualFS.unlinkSync(RESTNODE_SCRIPT_FNAME); + it('should do nothing when unable to read restnode script', async () => { + virtualFS.unlinkSync(rcUtils.RESTNODE_SCRIPT_FNAME); createTask({ gcEnabled: true, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.includeMatch(logs, /Unable to read "restnode" startup script/); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.includeMatch(logs, /Unable to read "restnode" startup script/); }); - it('should do nothing when unable to read configuration from the script file', () => { - virtualFS.writeFileSync(RESTNODE_SCRIPT_FNAME, 'something useless'); + it('should do nothing when unable to read configuration from the script file', async () => { + virtualFS.writeFileSync(rcUtils.RESTNODE_SCRIPT_FNAME, 'something useless'); createTask({ gcEnabled: true, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.includeMatch(logs, /No configuration read from the script/); - assert.includeMatch(logs, /The "restnode" startup script not modified!/); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.includeMatch(logs, /No configuration read from the script/); + assert.includeMatch(logs, /The "restnode" startup script not modified!/); }); - it('should not fail when unable to write data to file', () => { + it('should not fail when unable to write data to file', async () => { createTask({ id: '123' }); sinon.stub(virtualFS, 'writeFileSync').throws(new Error('test')); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /Done!/); - assert.includeMatch(logs, /Unable to write data to file/); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /Done!/); + assert.includeMatch(logs, /Unable to write data to file/); }); - it('should override logs', () => { + it('should override logs', async () => { createTask({ id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - virtualFS.writeFileSync(UPDATER_LOGS, 'checkpoint', { flags: 'a' }); - assert.includeMatch(getLogs(), /checkpoint/); - - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.notIncludeMatch(getLogs(), /checkpoint/); - }); + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + virtualFS.writeFileSync(rcUtils.UPDATER_LOGS, 'checkpoint', { flags: 'a' }); + assert.includeMatch(getLogs(), /checkpoint/); + + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.notIncludeMatch(getLogs(), /checkpoint/); }); - it('should apply empty configuration from the file', () => { + it('should apply empty configuration from the file', async () => { createTask({ id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Enabling GC config./); - assert.notIncludeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.notIncludeMatch(logs, /Upading heap size./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration from the file (GC only, example 1)', () => { + it('should apply configuration from the file (override max_old_space_size and max-old-space-size)', async () => { + createTask({ + id: '123', + heapSize: 1500, + gcEnabled: true, + httpTimeout: 130000 + }); + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 --max_old_space_size=2080 --max-old-space-size=2090 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.includeMatch(logs, /Upading heap size./); + assert.includeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + [ + '# ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', + '# To restore original behavior, uncomment the next line and remove the block below.', + '#', + '# exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 --max_old_space_size=2080 --max-old-space-size=2090 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + '#', + '# The block below should be removed to restore original behavior!', + '# ID:123', + 'exec /usr/bin/f5-rest-node --max-old-space-size=1500 --expose-gc /usr/share/rest/node/src/restnode.js -k 130000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ].join('\n') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: true, + heapSize: 1500, + httpTimeout: 130000, + id: '123' + } + ); + }); + + it('should apply empty configuration from the file (example 2)', async () => { + createTask({ id: '123' }); + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.includeMatch(logs, /Upading heap size./); + assert.includeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + [ + '# ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', + '# To restore original behavior, uncomment the next line and remove the block below.', + '#', + '# exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + '#', + '# The block below should be removed to restore original behavior!', + '# ID:123', + 'exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ].join('\n') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); + }); + + it('should apply configuration from the file (GC only, example 1)', async () => { createTask({ gcEnabled: true, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Enabling GC config./); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: true, - heapSize: 1400, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Upading heap size./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Enabling GC config./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: true, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration from the file (GC only, example 2)', () => { + it('should apply configuration from the file (GC only, example 2)', async () => { createTask({ gcEnabled: false, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Enabling GC config./); - assert.notIncludeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.notIncludeMatch(logs, /Upading heap size./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration from the file (heapSize only, example 1)', () => { + it('should apply configuration from the file (heapSize only, example 1)', async () => { createTask({ heapSize: 2000, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Enabling GC config./); - assert.includeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node --max_old_space_size=2000 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 2000, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Upading heap size./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=2000 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: 2000, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration from the file (heapSize only, example 2)', () => { + it('should apply configuration from the file (heapSize only, example 2)', async () => { createTask({ heapSize: 1400, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Enabling GC config./); - assert.notIncludeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.notIncludeMatch(logs, /Upading heap size./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration from the file (heapSize only, example 3)', () => { + it('should apply configuration from the file (heapSize only, example 3)', async () => { createTask({ heapSize: 500, id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - const logs = getLogs(); - assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); - assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); - assert.notIncludeMatch(logs, /No configuration read from the script/); - assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); - assert.notIncludeMatch(logs, /Enabling GC config./); - assert.includeMatch(logs, /Upading heap size./); - assert.notIncludeMatch(logs, /Upading memory allocator config/); - assert.includeMatch(logs, /Adding "notice" block to the script./); - assert.includeMatch(logs, /Done!/); - - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - // that's ok, default size of V8 heap, can't see to 500 without affecting other apps - heapSize: 1400, - id: '123' - } - ); - }); + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.notIncludeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Upading heap size./); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + // that's ok, default size of V8 heap, can't see to 500 without affecting other apps + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); + }); + + it('should set HTTP timeout to default value', async () => { + createTask({ id: '123', httpTimeout: 30000 }); + virtualFS.writeFileSync( + rcUtils.RESTNODE_SCRIPT_FNAME, + 'exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + const logs = getLogs(); + assert.notIncludeMatch(logs, /No config found, nothing to apply to the script/); + assert.notIncludeMatch(logs, /Unable to read "restnode" startup script/); + assert.notIncludeMatch(logs, /No configuration read from the script/); + assert.notIncludeMatch(logs, /The "restnode" startup script not modified!/); + assert.notIncludeMatch(logs, /Enabling GC config./); + assert.includeMatch(logs, /Upading heap size./); + assert.includeMatch(logs, /Upading HTTP timeout./); + assert.includeMatch(logs, /Setting HTTP timeout to default value/); + assert.includeMatch(logs, /Adding "notice" block to the script./); + assert.includeMatch(logs, /Done!/); + + assert.deepStrictEqual( + rcUtils.getScript(), + [ + '# ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', + '# To restore original behavior, uncomment the next line and remove the block below.', + '#', + '# exec /usr/bin/f5-rest-node --expose-gc --max_old_space_size=2060 --max-old-space-size=2030 /usr/share/rest/node/src/restnode.js -k 120000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', + '#', + '# The block below should be removed to restore original behavior!', + '# ID:123', + 'exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1' + ].join('\n') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); }); - it('should apply configuration', () => { + it('should apply configuration', async () => { createTask({ id: '123' }); updater.main(virtualFS); // sleep to let data be flushed to FS - return testUtil.sleep(10) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:123', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '123' - } - ); - createTask({ id: '456' }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:456', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '456' - } - ); - createTask({ - id: '456', - gcEnabled: true, - heapSize: 1500 - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:456', - ' exec /usr/bin/f5-rest-node --max_old_space_size=1500 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: true, - heapSize: 1500, - id: '456' - } - ); - createTask({ - id: '456', - gcEnabled: true, - heapSize: 1500 - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.notIncludeMatch(getLogs(), /Done!/); - assert.includeMatch(getLogs(), /The "restnode" startup script not modified!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:456', - ' exec /usr/bin/f5-rest-node --max_old_space_size=1500 --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: true, - heapSize: 1500, - id: '456' - } - ); - createTask({ - id: '456', - gcEnabled: false, - heapSize: 1600 - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:456', - ' exec /usr/bin/f5-rest-node --max_old_space_size=1600 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1600, - id: '456' - } - ); - createTask({ - id: '765', - gcEnabled: true, - heapSize: 500 - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:765', - ' exec /usr/bin/f5-rest-node --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: true, - heapSize: 1400, - id: '765' - } - ); - createTask({ - id: '765' - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:765', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '765' - } - ); - createTask({ - id: '765' - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /The "restnode" startup script not modified/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:765', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '765' - } - ); - createTask({ - id: '345' - }); - updater.main(virtualFS); - // sleep to let data be flushed to FS - return testUtil.sleep(10); - }) - .then(() => { - assert.includeMatch(getLogs(), /Done!/); - assert.deepStrictEqual( - getScript(), - [ - '#!/bin/sh', - '', - 'if [ -f /service/${service}/debug ]; then', - ' exec /usr/bin/f5-rest-node --debug /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'else', - ' # ATTENTION. The block below modified by F5 BIG-IP Telemetry Streaming!', - ' # To restore original behavior, uncomment the next line and remove the block below.', - ' #', - ' # exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - ' #', - ' # The block below should be removed to restore original behavior!', - ' # ID:345', - ' exec /usr/bin/f5-rest-node /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1', - 'fi', - '' - ].join('\n') - ); - assert.deepStrictEqual( - getCurrentConfig(), - { - gcEnabled: false, - heapSize: 1400, - id: '345' - } - ); - }); + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '123' + } + ); + createTask({ id: '456' }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: rcUtils.GC_DEFAULT, + heapSize: rcUtils.HEAP_SIZE_DEFAULT, + httpTimeout: rcUtils.HTTP_TIMEOUT_DEFAULT, + id: '456' + } + ); + createTask({ + id: '456', + gcEnabled: true, + heapSize: 1500, + httpTimeout: 130000 + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=1500 --expose-gc /usr/share/rest/node/src/restnode.js -k 130000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: true, + heapSize: 1500, + httpTimeout: 130000, + id: '456' + } + ); + createTask({ + id: '456', + gcEnabled: true, + heapSize: 1500, + httpTimeout: 130000 + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.notIncludeMatch(getLogs(), /Done!/); + assert.includeMatch(getLogs(), /The "restnode" startup script not modified!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=1500 --expose-gc /usr/share/rest/node/src/restnode.js -k 130000 -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: true, + heapSize: 1500, + httpTimeout: 130000, + id: '456' + } + ); + createTask({ + id: '456', + gcEnabled: false, + heapSize: 1600, + httpTimeout: 60000 + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --max-old-space-size=1600 /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: false, + heapSize: 1600, + httpTimeout: 60000, + id: '456' + } + ); + createTask({ + id: '765', + gcEnabled: true, + heapSize: 500 + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript('exec /usr/bin/f5-rest-node --expose-gc /usr/share/rest/node/src/restnode.js -p 8105 --logLevel finest -i ${LOG_FILE} -s none ${RCWFeature} >> /var/tmp/${service}.out 2>&1') + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: true, + heapSize: 1400, + httpTimeout: 60000, + id: '765' + } + ); + createTask({ + id: '765' + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: false, + heapSize: 1400, + httpTimeout: 60000, + id: '765' + } + ); + createTask({ + id: '765' + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /The "restnode" startup script not modified/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: false, + heapSize: 1400, + httpTimeout: 60000, + id: '765' + } + ); + createTask({ + id: '345' + }); + updater.main(virtualFS); + // sleep to let data be flushed to FS + await testUtil.sleep(10); + + assert.includeMatch(getLogs(), /Done!/); + assert.deepStrictEqual( + rcUtils.getScript(), + rcUtils.makeScript() + ); + assert.deepStrictEqual( + getCurrentConfig(), + { + gcEnabled: false, + heapSize: 1400, + httpTimeout: 60000, + id: '345' + } + ); }); }); }); diff --git a/test/unit/shared/assert.js b/test/unit/shared/assert.js index e0a1522f..c9e0882c 100644 --- a/test/unit/shared/assert.js +++ b/test/unit/shared/assert.js @@ -20,10 +20,77 @@ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const zip = require('lodash/zip'); +chai.config.includeStack = true; +chai.config.truncateThreshold = 0; + chai.use(chaiAsPromised); const assert = chai.assert; const additional = { + /** + * Asserts that all assertions from the list are passing + * + * @param {function[]} assertions + * @param {string} message + * + * @throws {Error} when assertion failed + */ + allOfAssertions(...assertions) { + assert.isAbove(assertions.length, 0); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + assertions.forEach((assertion, idx) => { + try { + assertion(); + } catch (error) { + error.message = `${message}assert.allOfAssertions: assertion #${idx} failed to pass the test: [${error.message || error}]`; + assert.ifError(error); + } + }); + }, + + /** + * Asserts that is at least one assertion from the list is passing + * + * @param {function[]} assertions + * @param {string} message + * + * @throws {Error} first failed assertion + */ + anyOfAssertions(...assertions) { + assert.isAbove(assertions.length, 0); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + const errors = []; + const success = assertions.some((assertion) => { + try { + assertion(); + } catch (error) { + errors.push(`#${errors.length + 1}: ${error.message || error}`); + return false; + } + return true; + }); + + if (success === false) { + assert.ifError(new Error(`${message}assert.anyOfAssertions: none assertions from the list are passing the test: [${errors.join(', ')}]`)); + } + }, + /** * Asserts that haystack includes needle * @@ -43,6 +110,66 @@ const additional = { } }, + /** + * Asserts that haystack does not include needle + * + * @param {Array|string} haystack - haystack + * @param {RegExp} needle - needle to search for in haystack + * @param {string} [message] - message to show on fail + */ + notIncludeMatch(haystack, needle, message) { + const checkFn = needle instanceof RegExp + ? ((elem) => needle.test(elem)) + : ((elem) => elem.indexOf(needle) !== -1); + const ok = Array.isArray(haystack) + ? haystack.some(checkFn) + : checkFn(haystack); + if (ok) { + assert.notInclude([].concat(haystack, needle), needle, message); + } + }, + + /** + * Asserts that only one assertion from the list is passing + * + * @param {function[]} assertions + * @param {string} message + */ + oneOfAssertions(...assertions) { + assert.isAbove(assertions.length, 0); + + let message = assertions[assertions.length - 1]; + if (typeof message === 'string') { + message = `${message}: `; + assertions.pop(); + } else { + message = ''; + } + + let lastPassIdx = -1; + const errors = []; + + assertions.forEach((assertion, idx) => { + try { + assertion(); + } catch (error) { + errors.push(`#${errors.length + 1}: ${error.message || error}`); + // it is OK to return here + return; + } + // assertion passed, need to check if it is the only one passing the test or not + if (lastPassIdx === -1) { + lastPassIdx = idx; + } else { + assert.ifError(new Error(`${message}assert.oneOfAssertions: assertions #${lastPassIdx} and #${idx} are both passing the test`)); + } + }); + + if (lastPassIdx === -1) { + assert.ifError(new Error(`${message}assert.oneOfAssertions: none assertions from the list are passing the test: [${errors.join(', ')}]`)); + } + }, + /** * Asserts that every element in haystack has match in the same order * @@ -61,25 +188,6 @@ const additional = { if (!ok) { assert.sameDeepOrderedMembers(sources, needles, message); } - }, - - /** - * Asserts that haystack does not include needle - * - * @param {Array|string} haystack - haystack - * @param {RegExp} needle - needle to search for in haystack - * @param {string} [message] - message to show on fail - */ - notIncludeMatch(haystack, needle, message) { - const checkFn = needle instanceof RegExp - ? ((elem) => needle.test(elem)) - : ((elem) => elem.indexOf(needle) !== -1); - const ok = Array.isArray(haystack) - ? haystack.some(checkFn) - : checkFn(haystack); - if (ok) { - assert.notInclude([].concat(haystack, needle), needle, message); - } } }; diff --git a/test/unit/shared/bigipAPIMock.js b/test/unit/shared/bigipAPIMock.js new file mode 100644 index 00000000..2d4dad33 --- /dev/null +++ b/test/unit/shared/bigipAPIMock.js @@ -0,0 +1,1095 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const sinon = require('sinon'); +const { v4: uuidv4 } = require('uuid'); + +const assert = require('./assert'); +const { deepCopy } = require('./util'); +const { + mockHttpEndpoint, + wrapMockNock, + wrapMockNockSet +} = require('./httpMock'); +const sourceCode = require('./sourceCode'); + +const { DEVICE_REST_API, LOCAL_HOST } = sourceCode('src/lib/constants'); +const utilMisc = sourceCode('src/lib/utils/misc'); + +const ALLOWED_PROTOS = [ + 'http', + 'https' +]; +const MAX_PORT_NUMBER = 2 ** 16; +const CONTENT_RAGE_RE = /^(\d+)-(\d+)\/(\d+)$/; + +class BigIpRestApiMock { + /** + * @param {string} [host = LOCAL_HOST] - host + * @param {Connection} [connection] + */ + constructor(host = LOCAL_HOST, { + port = DEVICE_REST_API.PORT, + protocol = DEVICE_REST_API.PROTOCOL + } = {}) { + assert.allOfAssertions( + () => assert.isString(host), + () => assert.isNotEmpty(host), + 'host should be a string' + ); + assert.allOfAssertions( + () => assert.isNumber(port), + () => assert.isAbove(port, 0), + () => assert.isBelow(port, MAX_PORT_NUMBER), + `options.port should be a number > 0 and < ${MAX_PORT_NUMBER}` + ); + assert.allOfAssertions( + () => assert.isString(protocol), + () => assert.isNotEmpty(protocol), + () => assert.include(ALLOWED_PROTOS, protocol), + `options.protocol should be a string and be one of allowed values: ${ALLOWED_PROTOS.join(',')}` + ); + + Object.defineProperties(this, { + host: { + value: host + }, + port: { + value: port + }, + protocol: { + value: protocol + } + }); + Object.defineProperties(this, { + origin: { + value: buildOrigin.call(this) + } + }); + + this.authTokens = []; + this.passwordLessUserAuthHeaders = []; + } + + /** + * @param {string} token + */ + addAuthToken(token) { + assert.allOfAssertions( + () => assert.isString(token), + () => assert.isNotEmpty(token), + 'token should be a string' + ); + this.authTokens.push(token); + } + + /** + * @param {string} username + */ + addPasswordlessUser(username) { + assert.allOfAssertions( + () => assert.isString(username), + () => assert.isNotEmpty(username), + 'username should be a string' + ); + this.allowPasswordLessAuth = true; + + const header = `Basic ${Buffer.from(`${username}:`).toString('base64')}`; + assert.notInclude(this.passwordLessUserAuthHeaders, header, `basic auth for "${username}" exists already!`); + this.passwordLessUserAuthHeaders.push(header); + } + + /** + * Mock an arbitrary endpoint + * + * @param {object} options + * + * @returns {MockNockStubBase} stub that has following signature + * + * function(uri: string, reqBody: any, req: object) { + * return [statusCode, response]; + * } + */ + mockArbitraryEndpoint({ + authCheck = true, + method = 'GET', + optionally = false, + path = undefined, + replyTimes = 1, + reqBody = undefined, + response = undefined, + responseSocketError = undefined, + socketTimeout = 0 + } = {}) { + assert.oneOfAssertions( + () => assert.isFunction(response, 'response should be a function'), + () => assert.allOfAssertions( + () => assert.isObject(responseSocketError), + () => assert.isNotEmpty(responseSocketError) + ), + 'response of responseSocketError should be defined' + ); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method, + optionally, + path, + replyTimes, + reqBody, + response: response + ? async (uri, rbody, req) => { + if (authCheck === true && !checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + return ret.stub(uri, rbody, req); + } + : undefined, + responseSocketError, + socketTimeout + }), + stub: sinon.stub() + }); + ret.stub.callsFake((uri, rbody, req) => response(uri, rbody, req)); + + return ret; + } + + /** + * @param {string} username + * @param {string} password + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(authData: {username: string, password: string}) { + * return [statusCode, { token: { token: 'token' } }, 'error message' || null]; + * } + */ + mockAuth(username, password, { optionally = false, replyTimes = 1 } = {}) { + const auth = { username, password }; + + Object.entries(auth).forEach(([key, value]) => assert.oneOfAssertions( + () => assert.instanceOf(value, RegExp), + () => assert.allOfAssertions( + () => assert.isString(value), + () => assert.isNotEmpty(value) + ), + `${key} should be a RegExp or a string` + )); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + badHeaders: ['Authorization', 'x-f5-auth-token'], + method: 'POST', + optionally, + path: '/mgmt/shared/authn/login', + reqBody: Object.assign(auth, { loginProviderName: 'tmos' }), + replyTimes, + response: async (uri, reqBody) => { + const [code, data, errorMsg] = await ret.stub(reqBody); + if (data && data.token && data.token.token) { + this.addAuthToken(data.token.token); + ret.authTokens.push(data.token.token); + } + return [code, errorMsg || data]; + } + }), + authTokens: [], + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, { token: { token: uuidv4() } }]); + + return ret; + } + + /** + * @param {RegExp | string} cmd + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {{ + * createScript: MockNockStubBase, + * updateScript: MockNockStubBase, + * createTask: MockNockStubBase, + * executeTask: MockNockStubBase, + * pollTaskResult: MockNockStubBase, + * removeTaskResult: MockNockStubBase, + * removeTask: MockNockStubBase, + * removeScript: MockNockStubBase + * }} stubs + * + * NOTE: See source code for function signatures + */ + mockDACLI(cmd, { optionally = false, replyTimes = 1, scriptName = '' } = {}) { + assert.oneOfAssertions( + () => assert.instanceOf(cmd, RegExp), + () => assert.allOfAssertions( + () => assert.isString(cmd), + () => assert.isNotEmpty(cmd) + ), + 'cmd should be a RegExp or a string' + ); + assert.oneOfAssertions( + () => assert.instanceOf(scriptName, RegExp), + () => assert.isString(scriptName), + 'scriptName should be a RegExp or a string' + ); + + /** + * All mocks are optional because DACLI may fail on attempt to auth + */ + const stubs = {}; + const scriptEndpointURI = '/mgmt/tm/cli/script'; + const taskEndpointURI = '/mgmt/tm/task/cli/script'; + const taskEndpointURISuffix = '/result'; + + function checkScriptName(reqName) { + if (!scriptName) { + return true; + } + if (typeof scriptName === 'string') { + return scriptName === reqName; + } + scriptName.lastIndex = 0; + return scriptName.test(reqName); + } + + function fetchScriptNameFromURI(uri) { + if (uri.startsWith(scriptEndpointURI)) { + return uri.slice(scriptEndpointURI.length + 1); + } + return ''; + } + + function fetchTaskIdFromURI(uri, hasSuffix) { + if (uri.startsWith(taskEndpointURI)) { + const taskId = uri.slice(taskEndpointURI.length + 1); + return taskId.slice( + 0, + taskId.length - (hasSuffix ? taskEndpointURISuffix.length : 0) + ); + } + return ''; + } + + // step #1 - create a new script + stubs.createScript = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'POST', + optionally, + path: scriptEndpointURI, + replyTimes, + reqBody: (reqBody) => reqBody.name && reqBody.apiAnonymous && checkScriptName(reqBody.name), + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.createScript.stub(reqBody); + return [code, errorMsg || data]; + } + }), + stub: sinon.stub(), + scripts: {} + }); + stubs.createScript.stub.callsFake((script) => { + stubs.createScript.scripts[script.name] = script; + return [200, { code: 200 }]; + }); + + // step #2 - update if the script exist + stubs.updateScript = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'PUT', + optionally, + path: (uri) => !!stubs.createScript.scripts[fetchScriptNameFromURI(uri).replace(/~/g, '/')], + replyTimes, + reqBody: (reqBody) => !!stubs.createScript.scripts[reqBody.name] && reqBody.apiAnonymous, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.updateScript.stub( + fetchScriptNameFromURI(uri), + reqBody + ); + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.updateScript.stub.callsFake(() => [200, {}]); + + // step #3 - create a new task + stubs.createTask = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'POST', + optionally, + path: taskEndpointURI, + replyTimes, + reqBody: (reqBody) => { + let isMatch = (reqBody.command === 'run') + && !!stubs.createScript.scripts[reqBody.name]; + + if (typeof cmd === 'string') { + isMatch = isMatch && reqBody.utilCmdArgs === cmd; + } else { + cmd.lastIndex = 0; + isMatch = isMatch && cmd.test(reqBody.utilCmdArgs); + } + return isMatch; + }, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.createTask.stub(uuidv4(), reqBody); + if (data && data._taskId) { + stubs.createTask.taskIds[data._taskId] = data._taskId; + } + return [code, errorMsg || data]; + } + }), + stub: sinon.stub(), + taskIds: {} + }); + stubs.createTask.stub.callsFake((_taskId) => [200, { _taskId }]); + + // step #4 - execute the task + stubs.executeTask = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'PUT', + optionally, + path: (uri) => !!stubs.createTask.taskIds[fetchTaskIdFromURI(uri)], + replyTimes, + reqBody: { + _taskState: 'VALIDATING' + }, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.executeTask.stub( + fetchTaskIdFromURI(uri), + reqBody + ); + return [code, errorMsg || data]; + } + }), + stub: sinon.stub(), + results: {} + }); + stubs.executeTask.stub.callsFake((taskId) => { + stubs.executeTask.results[taskId] = uuidv4(); + return [200, {}]; + }); + + // step #5 - poll the task's results + stubs.pollTaskResult = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'GET', + optionally, + path: (uri) => !!stubs.executeTask.results[fetchTaskIdFromURI(uri, true)], + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.pollTaskResult.stub( + fetchTaskIdFromURI(uri, true), + reqBody + ); + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.pollTaskResult.stub.callsFake(() => [200, { _taskState: 'COMPLETED' }]); + + // step #6 - remove the task's results + stubs.removeTaskResult = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'DELETE', + optionally, + path: (uri) => !!stubs.executeTask.results[fetchTaskIdFromURI(uri, true)], + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const taskID = fetchTaskIdFromURI(uri, true); + const [code, data, errorMsg] = await stubs.removeTaskResult.stub(taskID, reqBody); + + delete stubs.executeTask.results[taskID]; + + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.removeTaskResult.stub.callsFake(() => [200, '']); + + // step #7 - remove the task + stubs.removeTask = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'DELETE', + optionally, + path: (uri) => !!stubs.createTask.taskIds[fetchTaskIdFromURI(uri)], + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const taskID = fetchTaskIdFromURI(uri); + const [code, data, errorMsg] = await stubs.removeTask.stub(taskID, reqBody); + + delete stubs.createTask.taskIds[taskID]; + + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.removeTask.stub.callsFake(() => [200, '']); + + // step #8 - remove the script + stubs.removeScript = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'DELETE', + optionally, + path: (uri) => !!stubs.createScript.scripts[fetchScriptNameFromURI(uri).replace(/~/g, '/')], + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await stubs.removeScript.stub( + fetchScriptNameFromURI(uri), + reqBody + ); + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.removeScript.stub.callsFake(() => [200, '']); + + wrapMockNockSet(stubs); + return stubs; + } + + /** + * Mocks `decryptSecret` + * + * @returns {sinon.stub} stub that has following signature: + * + * function(...secrets) { + * return `decrypted_${secrets.join('')}; + * } + */ + mockDecryptSecret() { + const execFileStub = sinon.stub(utilMisc.childProcess, 'execFile'); + const decryptStub = sinon.stub(); + + decryptStub.callsFake(async (...secrets) => `decrypted_${secrets.join('')}`); + execFileStub.callsFake(async (...args) => { + if (args.length > 1 && args[0].includes('php') && args[1][0].includes('decryptConfValue')) { + return { + stderr: '', + stdout: await decryptStub(...args[1].slice(1)) + }; + } + return execFileStub.wrappedMethod.apply(utilMisc.childProcess, args); + }); + return decryptStub; + } + + /** + * @param {null | object | undefined} [deviceInfoData] + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function() { + * return [statusCode, { device: 'info' }, 'error message' || null]; + * } + */ + mockDeviceInfo(deviceInfoData, { optionally = false, replyTimes = 1 } = {}) { + deviceInfoData = deviceInfoData || { + baseMac: generateMacAddress(), + build: '0.0.0', + chassisSerialNumber: uuidv4(), + halUuid: uuidv4(), + hostMac: generateMacAddress(), + hostname: this.host, + isClustered: false, + isVirtual: true, + machineId: uuidv4(), + managementAddress: '192.168.1.10', + mcpDeviceName: `/Common/${this.host}`, + physicalMemory: 7168, + platform: 'Z100', + product: 'BIG-IP', + trustDomainGuid: uuidv4(), + version: '17.1.0', + generation: 0, + lastUpdateMicros: 0, + kind: 'shared:resolver:device-groups:deviceinfostate', + selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' + }; + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'GET', + optionally, + path: '/mgmt/shared/identified-devices/config/device-info', + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await ret.stub(); + return [code, errorMsg || data]; + } + }), + deviceInfoData, + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, deepCopy(ret.deviceInfoData)]); + + return ret; + } + + /** + * @param {string} [versionFileData] - version file data + * + * @returns {sinon.stub} stub that has following signature: + * + * function() { + * return Buffer.from('data'); + * } + */ + mockDeviceType(versionFileData) { + const readFileStub = utilMisc.fs.readFile; + const innerStub = sinon.stub(); + + versionFileData = versionFileData || [ + 'Product: BIG-IP', + `Version: ${['17', '1', '0', '3'].join('.')}`, + 'Build: 0.0.4', + `Sequence: ${['17', '1', '0', '3'].join('.')}-0.4.0`, + 'BaseBuild: 0.0.4', + 'Edition: Point Release 3', + 'Date: Wed Aug 23 10:18:11 PDT 2023', + 'Built: 230823101811', + 'Changelist: 3713935', + 'JobID: 1437207' + ].join('\n'); + + innerStub.callsFake(async () => Buffer.from(versionFileData)); + readFileStub.callsFake(async (...args) => { + if (args.length > 0 && args[0] === '/VERSION') { + // write data to virtual FS + utilMisc.fs.writeFileSync(args[0], await innerStub()); + } + return readFileStub.wrappedMethod.apply(utilMisc.fs, args); + }); + return innerStub; + } + + /** + * @param {null | object | undefined} [deviceVersionData] + * @param {string} [deviceVersionData.build] + * @param {string} [deviceVersionData.version] + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function() { + * return [statusCode, { device: 'version' }, 'error message' || null]; + * } + */ + mockDeviceVersion(deviceVersionData, { optionally = false, replyTimes = 1 } = {}) { + function assertVersionData(data) { + Object.entries(data).forEach(([key, value]) => { + assert.allOfAssertions( + () => assert.isString(value), + () => assert.isNotEmpty(value), + `${key} should be a string` + ); + }); + } + + deviceVersionData = deviceVersionData || { + build: '0.0.4', + version: ['17', '1', '0', '3'].join('.') + }; + assertVersionData(deviceVersionData); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'GET', + optionally, + path: '/mgmt/tm/sys/version', + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await ret.stub(); + return [code, errorMsg || data]; + } + }), + makeResponse(data) { + assertVersionData(data); + return { + kind: 'tm:sys:version:versionstats', + selfLink: 'https://localhost/mgmt/tm/sys/version?ver=17.1.0', + entries: { + 'https://localhost/mgmt/tm/sys/version/0': { + nestedStats: { + entries: { + Build: { + description: data.build + }, + Date: { + description: 'Wed Aug 23 10:18:11 PDT 2023' + }, + Edition: { + description: 'Point Release 3' + }, + Product: { + description: 'BIG-IP' + }, + Title: { + description: 'Main Package' + }, + Version: { + description: data.version + } + } + } + } + } + }; + }, + deviceVersionData, + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, ret.makeResponse(deepCopy(ret.deviceVersionData))]); + + return ret; + } + + /** + * @param {RegExp | string} fileUri + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(uri: string, range: {start: number, end: number, size: number}) { + * return [statusCode, buffer, range, 'error message' || null]; + * } + */ + mockDownloadFileFromDevice(fileUri, { optionally = false, replyTimes = 1 } = {}) { + assert.oneOfAssertions( + () => assert.instanceOf(fileUri, RegExp), + () => assert.allOfAssertions( + () => assert.isString(fileUri), + () => assert.isNotEmpty(fileUri) + ), + 'uri should be a RegExp or a string' + ); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'GET', + optionally, + path: fileUri, + replyTimes, + reqHeaders: { + 'Content-Range': /\d+-\d+\/\d+/, + 'Content-Type': 'application/octet-stream' + }, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + + const crane = req.headers['content-range'].match(CONTENT_RAGE_RE); + const range = { + start: parseInt(crane[1], 10), + end: parseInt(crane[2], 10), + size: parseInt(crane[3], 10) + }; + + const [code, data, responseRange, errorMsg] = await ret.stub(uri, range); + return [ + code, + errorMsg || data, + errorMsg + ? {} + : { + 'Content-Range': `${responseRange.start}-${responseRange.end}/${responseRange.size}`, + 'Content-Type': 'application/octet-stream' + } + ]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake(() => { + const data = Buffer.from('test'); + return [200, data, { start: 0, end: data.length - 1, size: data.length }]; + }); + + return ret; + } + + /** + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {{ + * delete: MockNockStubBase, + * encrypt: MockNockStubBase, + * }} stubs + */ + mockEncryptSecret({ optionally = false, replyTimes = 1 } = {}) { + const radiusURI = '/mgmt/tm/ltm/auth/radius-server'; + + function fetchRadiustNameFromURI(uri) { + if (uri.startsWith(radiusURI)) { + return uri.slice(radiusURI.length + 1); + } + return ''; + } + + const stubs = {}; + stubs.encrypt = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'POST', + optionally, + path: radiusURI, + replyTimes, + reqBody: (reqBody) => /telemetry_delete_me/.test(reqBody.name) && reqBody.server === 'foo' && reqBody.secret, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + + const [code, data, errorMsg] = await stubs.encrypt.stub(reqBody); + stubs.encrypt.secrets[reqBody.name] = reqBody; + + return [code, errorMsg || data]; + } + }), + secrets: {}, + stub: sinon.stub() + }); + stubs.encrypt.stub.callsFake((reqBody) => [200, { + server: reqBody.server, + secret: `$M$${Buffer.from(reqBody.secret).toString('base64')}` + }]); + + stubs.delete = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'DELETE', + optionally, + path: (uri) => !!stubs.encrypt.secrets[fetchRadiustNameFromURI(uri)], + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + + const radiusName = fetchRadiustNameFromURI(uri); + const [code, data, errorMsg] = await stubs.delete.stub( + radiusName, + stubs.encrypt.secrets[radiusName] + ); + + delete stubs.encrypt.secrets[radiusName]; + return [code, errorMsg || data]; + } + }), + stub: sinon.stub() + }); + stubs.delete.stub.callsFake(() => [200, '']); + + wrapMockNockSet(stubs); + return stubs; + } + + /** + * @param {RegExp | string} cmd + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(cmdArgs: string) { + * return [statusCode, 'command result', 'error message' || null]; + * } + */ + mockExecuteShellCommandOnDevice(cmd, { optionally = false, replyTimes = 1 } = {}) { + assert.oneOfAssertions( + () => assert.instanceOf(cmd, RegExp), + () => assert.allOfAssertions( + () => assert.isString(cmd), + () => assert.isNotEmpty(cmd) + ), + 'cmd should be a RegExp or a string' + ); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'POST', + optionally, + path: '/mgmt/tm/util/bash', + replyTimes, + reqBody: (reqBody) => { + let isMatch = false; + if (reqBody.command === 'run') { + if (typeof cmd === 'string') { + isMatch = reqBody.utilCmdArgs === `-c "${cmd}"`; + } else { + cmd.lastIndex = 0; + isMatch = cmd.test(reqBody.utilCmdArgs); + } + } + return isMatch; + }, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + + const [code, commandResult, errorMsg] = await ret.stub(reqBody.utilCmdArgs); + return [code, errorMsg || { commandResult }]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, cmd]); + + return ret; + } + + /** + * @param {boolean} enabled + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function() { + * return [statusCode, { value: 'false' }, 'error message' || null]; + * } + */ + mockIsShellEnabled(enabled, { optionally = false, replyTimes = 1 } = {}) { + assert.isBoolean(enabled, 'enabled should be a boolean'); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'GET', + optionally, + path: '/mgmt/tm/sys/db/systemauth.disablebash', + replyTimes, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + const [code, data, errorMsg] = await ret.stub(reqBody); + return [code, errorMsg || data]; + } + }), + shellEnabled: enabled, + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, { value: `${!ret.shellEnabled}` }]); + + return ret; + } + + /** + * @param {RegExp | string} pathToCheck + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(filePath: string) { + * return [statusCode, 'command result', 'error message' || null]; + * } + */ + mockPathExists(pathToCheck, options) { + return mockTMUtilUnixCommand.call(this, 'ls', pathToCheck, options); + } + + /** + * @param {RegExp | string} pathToRemove + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(filePath: string) { + * return [statusCode, 'command result', 'error message' || null]; + * } + */ + mockRemovePath(pathToRemove, options) { + return mockTMUtilUnixCommand.call(this, 'rm', pathToRemove, options); + } +} + +/** + * @private + * + * Build origin part of URI + * + * @this {BigIpRestApiMock} + * + * @returns {string} + */ +function buildOrigin() { + return `${this.protocol}://${this.host}:${this.port}`; +} + +/** + * @private + * + * @param {object} headers - request headers + * + * @returns {true} if check passed + */ +function checkAuthHeaders(headers) { + let authPassed = false; + + if (!authPassed && headers.authorization && this.allowPasswordLessAuth) { + authPassed = this.passwordLessUserAuthHeaders.includes(headers.authorization); + } + if (!authPassed && headers['x-f5-auth-token']) { + authPassed = this.authTokens.includes(headers['x-f5-auth-token']); + } + return authPassed; +} + +/** @returns {string} random MAC address */ +function generateMacAddress() { + return 'XX:XX:XX:XX:XX:XX'.replace(/X/g, () => '0123456789ABCDEF'.charAt(Math.floor(Math.random() * 16))); +} + +/** + * @private + * + * @param {string} unixCmd + * @param {RegExp | string} cmdArgs + * @param {object} [options] + * @param {boolean} [options.optionally = false] + * @param {number} [options.replyTimes = 1] + * + * @returns {MockNockStubBase} stub that has following signature: + * + * function(filePath: string) { + * return [statusCode, 'command result', 'error message' || null]; + * } + */ +function mockTMUtilUnixCommand(unixCmd, cmdArgs, { optionally = false, replyTimes = 1 } = {}) { + const allowed = ['ls', 'rm', 'mv']; + assert(allowed.includes(unixCmd), `cmd should be one from ${allowed}`); + + assert.oneOfAssertions( + () => assert.instanceOf(cmdArgs, RegExp), + () => assert.allOfAssertions( + () => assert.isString(cmdArgs), + () => assert.isNotEmpty(cmdArgs) + ), + 'cmdArgs should be a RegExp or a string' + ); + + // TODO: update it to work with `mv` command + const cleanupPath = (fpathArg) => fpathArg.slice( + fpathArg[0] === '"' ? 1 : 0, + fpathArg[fpathArg.length - 1] === '"' ? (fpathArg.length - 1) : fpathArg.length + ); + + const ret = wrapMockNock({ + ...mockHttpEndpoint(this.origin, { + method: 'POST', + optionally, + path: `/mgmt/tm/util/unix-${unixCmd}`, + replyTimes, + reqBody: (reqBody) => { + let isMatch = false; + if (reqBody.command === 'run') { + const fpathArg = cleanupPath(reqBody.utilCmdArgs); + if (typeof cmdArgs === 'string') { + isMatch = fpathArg === cmdArgs; + } else { + cmdArgs.lastIndex = 0; + isMatch = cmdArgs.test(fpathArg); + } + } + return isMatch; + }, + response: async (uri, reqBody, req) => { + if (!checkAuthHeaders.call(this, req.headers)) { + return [401, 'Unauthorized']; + } + + const [code, commandResult, errorMsg] = await ret.stub(cleanupPath(reqBody.utilCmdArgs)); + return [code, errorMsg || { commandResult }]; + } + }), + stub: sinon.stub() + }); + ret.stub.callsFake(() => [200, '']); + + return ret; +} + +module.exports = BigIpRestApiMock; + +/** + * @typedef {object} Connection + * @property {number} [port = DEVICE_REST_API.PORT] + * @property {string} [protocol = DEVICE_REST_API.PROTOCOL] + */ +/** + * @typedef {object} MockNockStubBase + * @property {nock.Interceptor} interceptor - request interceptor + * @property {nock.Scope} scope - request scope + * @property {sinon.stub} stub - stub function to call on request match + * @property {function} disable - make optional + * @property {function} remove - remove from HTTP trap + */ diff --git a/test/unit/shared/bootstrap.js b/test/unit/shared/bootstrap.js index 6b272f05..f36f4fe6 100644 --- a/test/unit/shared/bootstrap.js +++ b/test/unit/shared/bootstrap.js @@ -16,13 +16,8 @@ 'use strict'; -const values = require('object.values'); require('./restoreCache'); -if (!Object.values) { - values.shim(); -} - /* eslint-disable no-console */ process.on('unhandledRejection', (reason, promise) => { @@ -30,10 +25,4 @@ process.on('unhandledRejection', (reason, promise) => { throw reason; }); -// because we're restoring cache -// it instantiates monitor that's supposed to be singleton -// so set these to work around tests -process.setMaxListeners(15); -// tests needing the monitor should manually enable these -// constants.APP_THRESHOLDS.MONITOR_DISABLED -process.env.MONITOR_DISABLED = true; +console.log((!!process.env.SMOKE_TESTING && 'SMOKE TESTING ENABLED') || 'ALL UNIT TESTS'); diff --git a/test/unit/shared/dummies.js b/test/unit/shared/dummies.js index 30173064..ee6c255f 100644 --- a/test/unit/shared/dummies.js +++ b/test/unit/shared/dummies.js @@ -20,8 +20,6 @@ const defaultsDeep = require('lodash/defaultsDeep'); const cloneDeep = require('lodash/cloneDeep'); const setByKey = require('lodash/set'); -// TODO: add tests for this file later - /** * Merge data * @@ -372,6 +370,12 @@ module.exports = { class: 'Telemetry_Pull_Consumer', type: 'default' }) + }, + prometheus: { + decrypted: declarationComponentGenerator({ + class: 'Telemetry_Pull_Consumer', + type: 'Prometheus' + }) } }, system: { diff --git a/test/unit/shared/httpMock.js b/test/unit/shared/httpMock.js new file mode 100644 index 00000000..38b2aa0d --- /dev/null +++ b/test/unit/shared/httpMock.js @@ -0,0 +1,423 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const nock = require('nock'); +const { Scope } = require('nock/lib/scope'); +const { URLSearchParams } = require('url'); + +const assert = require('./assert'); +const { deepCopy } = require('./util'); + +/** + * Mock REST API endpoint with Nock library + * + * See Nock v11 documentation for additional details + * + * @property {string} host - host + * @property {object} config - mock configuration + * @property {(function(string): boolean) | RegExp | string } config.path - URI path to match + * + * Scope-wide options + * @property {boolean} [config.autoContentLength = true] - calculate Content-Length header automatically + * @property {string[]} [config.badHeaders] - do not match request if any of the `badHeaders` are present + * @property {(function(any): any) | object} [config.bodyFilter] - request body pre-filtering + * @property {object} [config.defaultReplyHeaders = {}] - default reply headers + * @property {boolean} [config.encodedQueryParams = false] + * @property {object} [config.matchHeaders] - match certain + * request headers only + * @property {(function(string): string) | object} [config.pathFilter] - path pre-filtering + * @property {object} [config.reqHeaders = {}] - request headers to match + * @property {nock.Scope} [config.scope] - existing nock.Scope instance to re-use + * @property {function(): boolean} [config.scopeConditional] - scope conditional pre-filtering + * @property {function(string): boolean} [config.scopeFilter] - scope pre-filtering + * + * Request-wide options + * @property {{ user: string, pass: string }} [config.basicAuth = undefined] - specify basic auth for request + * @property {string} [config.method = 'GET'] - HTTP method to match + * @property {boolean} [config.optionally = false] - is request optional or not + * @property {number | {{ body: number, head: number }} } [config.replyDelay = 0] - reply delay + * @property {number} [config.replyTimes = 0] - number of times to repeat the same response + * @property {Buffer | (function(): boolean) | object | RegExp | string} [config.reqBody] - request body to match + * @property {boolean | (function(object): boolean) + * | object | URLSearchParams} [config.reqQuery] - request query to match + * @property {function(uri: string, reqBody: any, req: Object):(number | [number, any, object])} [config.response] + * @property {object | string} [config.responseSocketError] - reply with socket error + * @property {number} [config.socketTimeout = 0] - socket timeout simulation (in ms.) + * + * @returns {{interceptor: nock.Interceptor, scope: nock.Scope}} + */ +function mockHttpEndpoint(host, { + autoContentLength = true, + badHeaders = [], + basicAuth = {}, + bodyFilter = {}, + defaultReplyHeaders = {}, + encodedQueryParams = false, + includeDateHeader = false, + matchHeaders = {}, + method = 'GET', + optionally = false, + path = undefined, + pathFilter = {}, + replyDelay = 0, + replyTimes = 0, + reqBody = undefined, + reqHeaders = {}, + reqQuery = undefined, + response = undefined, + responseSocketError = undefined, + scope = null, + scopeConditional = undefined, + scopeFilter = undefined, + socketTimeout = 0 +} = {}) { + const config = arguments[1]; + + function assertObject(value, msg = 'expected value to be an object') { + assert.allOfAssertions( + () => assert.isNotNull(value), + () => assert.isObject(value), + msg + ); + } + + /** required */ + assert.allOfAssertions( + () => assert.isString(host), + () => assert.isNotEmpty(host), + 'host should be a string' + ); + assert.allOfAssertions( + () => assertObject(config), + () => assert.isNotEmpty(config), + 'config should be an object' + ); + assert.oneOfAssertions( + () => assert.isFunction(path), + () => assert.instanceOf(path, RegExp), + () => assert.isString(path), + 'config.path should be a function or a RegExp or a string' + ); + + /** optional */ + assert.isBoolean(autoContentLength, 'config.autoContentLength should be a boolean'); + assert.isArray(badHeaders, 'config.badHeaders should be an array'); + assert.oneOfAssertions( + () => assertObject(bodyFilter), + () => assert.isFunction(bodyFilter), + 'config.bodyFilter should be an object or a function' + ); + assert.allOfAssertions( + () => assertObject(basicAuth), + () => assert.oneOfAssertions( + () => assert.isEmpty(basicAuth), + () => assert.allOfAssertions( + () => assert.allOfAssertions( + () => assert.isString(basicAuth.user), + () => assert.isNotEmpty(basicAuth.user), + '"user" property should not be empty' + ), + () => assert.oneOfAssertions( + () => assert.isUndefined(basicAuth.pass), + () => assert.isString(basicAuth.pass) + ), + '"pass" property should be an undefined or a string' + ) + ), + 'config.basicAuth should be an object' + ); + assertObject(defaultReplyHeaders, 'config.defaultReplyHeaders should be an object'); + assert.isBoolean(encodedQueryParams, 'config.encodedQueryParams should be a boolean'); + assert.oneOfAssertions( + () => assert.isBoolean(includeDateHeader), + () => assert.instanceOf(includeDateHeader, Date), + 'config.includeDateHeader should be a boolean or instnace of Date' + ); + assertObject(matchHeaders, 'config.matchHeaders should be an object'); + assert.isString(method, 'config.method should be a string'); + assert.isBoolean(optionally, 'config.optionally should be a boolean'); + assert.oneOfAssertions( + () => assertObject(pathFilter), + () => assert.isFunction(pathFilter), + 'config.pathFilter should be an object or a function' + ); + assert.oneOfAssertions( + () => assert.allOfAssertions( + () => assert.isNumber(replyDelay), + () => assert.isAtLeast(socketTimeout, 0), + () => assert.isAtMost(socketTimeout, Number.MAX_SAFE_INTEGER), + 'config.replyDelay should be a number >= 0 and <= Number.MAX_SAFE_INTEGER' + ), + () => assert.allOfAssertions( + () => assertObject(replyDelay), + () => assert.anyOfAssertions( + () => assert.isDefined(replyDelay.body), + () => assert.isDefined(replyDelay.head), + 'should have "body" and/or "head" properties' + ), + () => assert.allOfAssertions( + ...['body', 'head'].map((prop) => { + const val = replyDelay[prop]; + return () => assert.oneOfAssertions( + () => assert.isUndefined(val), + () => assert.allOfAssertions( + () => assert.isNumber(val), + () => assert.isAtLeast(val, 0), + () => assert.isAtMost(val, Number.MAX_SAFE_INTEGER) + ), + `"${prop}" should be a number >= 0 and <= Number.MAX_SAFE_INTEGER` + ); + }) + ), + 'config.replyDelay should be an object' + ), + 'config.replyDelay should be a number or an object' + ); + assert.allOfAssertions( + () => assert.isNumber(replyTimes), + () => assert.isNotNaN(replyTimes), + () => assert.oneOfAssertions( + () => assert.isFalse(Number.isFinite(replyTimes)), + () => assert.allOfAssertions( + () => assert.isAtLeast(replyTimes, 0), + () => assert.isAtMost(replyTimes, Number.MAX_SAFE_INTEGER) + ) + ), + 'config.replyTimes should be a number >= 0 and <= Number.MAX_SAFE_INTEGER or INFITITY' + ); + assert.oneOfAssertions( + () => assert.isUndefined(reqBody), + () => assert.instanceOf(reqBody, Buffer), + () => assert.instanceOf(reqBody, RegExp), + () => assert.isFunction(reqBody), + () => assert.isString(reqBody), + () => assertObject(reqBody), + 'config.reqBody should be an instance of Buffer or a function or an object or a RegExp or a string' + ); + + assertObject(reqHeaders, 'config.reqHeaders should be an object'); + assert.oneOfAssertions( + () => assert.isUndefined(reqQuery), + () => assert.isTrue(reqQuery), + () => assert.isFunction(reqQuery), + () => assert.instanceOf(reqQuery, URLSearchParams), + () => assert.allOfAssertions( + () => assertObject(reqQuery), + () => assert.isNotEmpty(reqQuery) + ), + 'config.reqQuery should be a boolean or a function or an object or a string or an instance of URLSearchParams' + ); + + assert.oneOfAssertions( + () => assert.isUndefined(response), + () => assert.isFunction(response), + 'config.response should be a function' + ); + assert.oneOfAssertions( + () => assert.isUndefined(responseSocketError), + () => assert.allOfAssertions( + () => assert.oneOfAssertions( + () => assert.isString(responseSocketError), + () => assertObject(responseSocketError)(responseSocketError) + ), + () => assert.isNotEmpty(responseSocketError) + ), + 'config.responseSocketError should be an object or a string' + ); + assert.oneOfAssertions( + () => assert.isDefined(response), + () => assert.isDefined(responseSocketError), + 'config.responseSocketError or config.response should be set' + ); + assert.oneOfAssertions( + () => assert.isNull(scope), + () => assert.instanceOf(scope, Scope), + 'config.scope should be an instance of Scope' + ); + assert.oneOfAssertions( + () => assert.isUndefined(scopeConditional), + () => assert.isFunction(scopeConditional), + 'config.scopeConditional should be a function' + ); + assert.oneOfAssertions( + () => assert.isUndefined(scopeFilter), + () => assert.isFunction(scopeFilter), + 'config.scopeFilter should be a function' + ); + assert.allOfAssertions( + () => assert.isNumber(socketTimeout), + () => assert.isAtLeast(socketTimeout, 0), + () => assert.isAtMost(socketTimeout, Number.MAX_SAFE_INTEGER), + 'config.socketTimeout should be a number >= 0 and <= Number.MAX_SAFE_INTEGER' + ); + + /** + * HOST SCOPE configuration + */ + scope = scope || nock(host, Object.assign( + { encodedQueryParams }, + Object.keys(reqHeaders).length > 0 ? { reqheaders: reqHeaders } : {}, + badHeaders.length > 0 ? { badheaders: deepCopy(badHeaders) } : {}, + typeof scopeConditional === 'function' ? { conditionally: scopeConditional } : {}, + typeof scopeFilter === 'function' ? { filteringScope: scopeFilter } : {} + )); + + if (Number.isFinite(replyTimes) === false) { + scope = scope.persist(); + } + + if (typeof bodyFilter === 'function') { + scope = scope.filteringRequestBody(bodyFilter); + } else if (Object.keys(bodyFilter).length > 0) { + const [filter, value] = Object.entries(bodyFilter)[0]; + scope = scope.filteringRequestBody(filter, value); + } + + if (typeof pathFilter === 'function') { + scope = scope.filteringPath(pathFilter); + } else if (Object.keys(pathFilter).length > 0) { + const [filter, value] = Object.entries(pathFilter)[0]; + scope = scope.filteringPath(filter, value); + } + + Object.entries(matchHeaders).forEach(([header, value]) => { + scope = scope.matchHeader(header, value); + }); + + if (includeDateHeader) { + scope = scope.replyDate(includeDateHeader); + } + + if (autoContentLength) { + scope = scope.replyContentLength(); + } + + if (Object.keys(defaultReplyHeaders).length > 0) { + scope = scope.defaultReplyHeaders(deepCopy(defaultReplyHeaders)); + } + + /** + * PATH/REQUEST SCOPE configuration + */ + + let interceptor = scope.intercept(path, method.toUpperCase(), reqBody); + + if (Object.keys(basicAuth).length > 0) { + interceptor = interceptor.basicAuth(deepCopy(basicAuth)); + } + + if (typeof reqQuery !== 'undefined') { + interceptor = interceptor.query(reqQuery); + } + + interceptor = interceptor.optionally(optionally); + + if (socketTimeout > 0) { + interceptor = interceptor.socketDelay(socketTimeout); + } + + if (typeof replyDelay === 'object' || Number.isSafeInteger(replyDelay)) { + interceptor = interceptor.delay(deepCopy(replyDelay)); + } + + if (Number.isFinite(replyTimes) && replyTimes > 0) { + interceptor = interceptor.times(replyTimes); + } + + if (response) { + scope = interceptor.reply(async function (uri, requestBody, cb) { + let fullReply; + try { + fullReply = await response(uri, requestBody, this.req); + } catch (error) { + return cb(error, null); + } + return cb(null, Number.isSafeInteger(fullReply) ? [fullReply] : fullReply); + }); + } else { + scope = interceptor.replyWithError(deepCopy(responseSocketError)); + } + + return { interceptor, scope }; +} + +/** + * @public + * + * @param {MockNockStubBase} mock + * + * @returns {MockNockStubBase} + */ +function wrapMockNock(mock) { + mock.disable = () => mock.interceptor.optionally(true); + mock.remove = () => removeInterceptor(mock); + return mock; +} + +/** + * @public + * + * @param {Object} stubs + * + * @returns {Object} + */ +function wrapMockNockSet(stubs) { + stubs.disable = () => Object.entries(stubs) + .forEach((entry) => { + if (typeof entry[1] === 'object' && typeof entry[1].disable === 'function') { + entry[1].disable(); + } + }); + stubs.remove = () => Object.entries(stubs) + .forEach((entry) => { + if (typeof entry[1] === 'object' && typeof entry[1].remove === 'function') { + entry[1].remove(); + } + }); + + return stubs; +} + +/** + * @public + * + * @param {MockNockStubBase} stub + */ +function removeInterceptor(stub) { + // when scope configured with `persist` option then + // .remove does not removes the mock. + // Need to use nock.removeInterceptor or configure + // mock with replyTimes instead + stub.scope.remove(stub.interceptor._key, stub.interceptor); +} + +module.exports = { + mockHttpEndpoint, + removeInterceptor, + wrapMockNock, + wrapMockNockSet +}; + +/** + * @typedef {object} MockNockStubBase + * @property {nock.Interceptor} interceptor - request interceptor + * @property {nock.Scope} scope - request scope + * @property {sinon.stub} stub - stub function to call on request match + * @property {function} disable - make optional + * @property {function} remove - remove from HTTP trap + */ diff --git a/test/unit/shared/restoreCache.js b/test/unit/shared/restoreCache.js index 539f1082..d952292a 100644 --- a/test/unit/shared/restoreCache.js +++ b/test/unit/shared/restoreCache.js @@ -43,12 +43,11 @@ */ // preload popular libs here for optimization -/* eslint-disable no-unused-vars */ -const pathUtil = require('path'); -const sinon = require('sinon'); -const assert = require('./assert'); -const fileLogger = require('../../winstonLogger'); +const pathUtil = require('path'); +require('sinon'); +require('./assert'); +require('../../winstonLogger'); const BASE_DIR = process.cwd(); const SRC_DIR = pathUtil.join(BASE_DIR, 'src'); diff --git a/test/unit/shared/stubs.js b/test/unit/shared/stubs.js index 5752f90f..692b4c40 100644 --- a/test/unit/shared/stubs.js +++ b/test/unit/shared/stubs.js @@ -18,16 +18,21 @@ const EventEmitter2 = require('eventemitter2'); const memfs = require('memfs'); +const nodeFS = require('fs'); +const nodeUtil = require('util'); const pathUtil = require('path'); const sinon = require('sinon'); +const assert = require('./assert'); const assignDefaults = require('./util').assignDefaults; +const BigIpRestApiMock = require('./bigipAPIMock'); const deepCopy = require('./util').deepCopy; const tsLogsForwarder = require('../../winstonLogger').tsLogger; const sourceCode = require('./sourceCode'); -const constants = sourceCode('src/lib/constants'); -const promisifyNodeFsModule = sourceCode('src/lib/utils/misc').promisifyNodeFsModule; +/** + * @typedef {import("./bigipAPIMock").MockNockStubBase} MockNockStubBase + */ let SINON_FAKE_CLOCK = null; @@ -50,6 +55,24 @@ function addStubRestore(stub, restoreFn) { // reference to module.exports // eslint-disable-next-line no-multi-assign const _module = module.exports = { + /** + * Stub for Application Events + * + * @param {object} AppEvents - class + * + * @returns {AppEventsStubCtx} stub context + */ + appEvents(AppEvents) { + const ctx = { + appEvents: new AppEvents(), + stub: sinon.stub() + }; + addStubRestore(ctx.stub, () => { + ctx.appEvents.stop(); + }); + return ctx; + }, + /** * Sinon fake timers * @@ -170,8 +193,6 @@ const _module = module.exports = { * @param {ConfigWorker} [coreModules.configWorker] - config worker * @param {module} [coreModules.deviceUtil] - Device Utils module * @param {module} [coreModules.logger] - Logger module - * @param {module} [coreModules.persistentStorage] - Persistent Storage module - * @param {module} [coreModules.teemReporter] - Teem Reporter module * @param {module} [coreModules.tracer] - Tracer module * @param {module} [coreModules.utilMisc] - Utils (misc.) module * @param {object} [options] - options, see each stub for additional info @@ -179,35 +200,81 @@ const _module = module.exports = { * @returns {CoreStubCtx} stubs for core modules */ coreStub(coreModules, options) { + const localhostBigIp = new BigIpRestApiMock(); + localhostBigIp.addPasswordlessUser('admin'); + options = options || {}; - const ctx = {}; + const ctx = { + localhostBigIp + }; + if (coreModules.deviceUtil || coreModules.tracer || coreModules.utilMisc) { + coreModules.utilMisc = coreModules.utilMisc || sourceCode('src/lib/utils/misc'); + ctx.utilMisc = _module.utilMisc(coreModules.utilMisc, options.utilMisc); + } + if (coreModules.tracer) { + ctx.tracer = _module.tracer(coreModules.tracer, options.tracer, ctx); + } + if (coreModules.appEvents) { + ctx.appEvents = _module.appEvents(coreModules.appEvents, options.appEvents); + } if (coreModules.configWorker) { - ctx.configWorker = _module.configWorker(coreModules.configWorker, options.configWorker); + ctx.configWorker = _module.configWorker(coreModules.configWorker, options.configWorker, ctx); } if (coreModules.deviceUtil) { - ctx.deviceUtil = _module.deviceUtil(coreModules.deviceUtil, options.deviceUtil); + ctx.deviceUtil = _module.deviceUtil(coreModules.deviceUtil, options.deviceUtil, ctx); } if (coreModules.logger) { ctx.logger = _module.logger(coreModules.logger, options.logger); } - if (coreModules.persistentStorage) { - ctx.persistentStorage = _module.persistentStorage(coreModules.persistentStorage, options.persistentStorage); - } if (coreModules.resourceMonitorUtils) { ctx.resourceMonitorUtils = _module.resourceMonitorUtils( coreModules.resourceMonitorUtils, options.resourceMonitorUtils ); } - if (coreModules.teemReporter) { - ctx.teemReporter = _module.teemReporter(coreModules.teemReporter, options.teemReporter); - } - if (coreModules.tracer) { - ctx.tracer = _module.tracer(coreModules.tracer, options.tracer); - } - if (coreModules.utilMisc) { - ctx.utilMisc = _module.utilMisc(coreModules.utilMisc, options.utilMisc); + if (coreModules.storage) { + ctx.storage = _module.storage( + coreModules.storage, + options.storage, + ctx + ); } + + ctx.destroyServices = async () => { + if (ctx.storage && ctx.storage.service.isRunning()) { + if (ctx.configWorker) { + await ctx.configWorker.configWorker.cleanup(); + } + await ctx.storage.service.destroy(); + } + // time to remove all listeners + if (ctx.appEvents) { + ctx.appEvents.appEvents.stop(); + } + }; + ctx.restartServices = async () => { + // start first, dependency for configWorker + if (ctx.storage) { + await ctx.storage.service.restart(); + if (ctx.configWorker) { + await ctx.configWorker.configWorker.load(); + } + } + }; + ctx.startServices = async () => { + // start first, dependency for configWorker + if (ctx.storage) { + await ctx.storage.service.start(); + if (ctx.configWorker) { + await ctx.configWorker.configWorker.cleanup(); + } + } + }; + ctx.stopServices = async () => { + if (ctx.storage && ctx.storage.service.isRunning()) { + await ctx.storage.service.stop(); + } + }; return ctx; }, @@ -215,10 +282,12 @@ const _module = module.exports = { * Stub for Config Worker * * @param {ConfigWorker} configWorker - instance of ConfigWorker + * @param {object} options + * @param {CoreStubCtx} coreCtx * * @returns {ConfigWorkerStubCtx} stub context */ - configWorker(configWorker) { + configWorker(configWorker, options, coreCtx) { const ctx = _module.eventEmitter(configWorker); ctx.configs = []; ctx.receivedSpy = sinon.spy(); @@ -228,6 +297,12 @@ const _module = module.exports = { configWorker.on('received', ctx.receivedSpy); configWorker.on('validationFailed', ctx.validationFailedSpy); configWorker.on('validationSucceed', ctx.validationSucceedSpy); + + if (coreCtx.appEvents) { + configWorker.initialize(coreCtx.appEvents.appEvents); + } + + ctx.configWorker = configWorker; return ctx; }, @@ -235,18 +310,28 @@ const _module = module.exports = { * Stub for Device Utils * * @param {module} deviceUtil - module + * @param {object} options + * @param {CoreStubCtx} coreCtx * * @returns {DeviceUtilStubCtx} stub context */ - deviceUtil(deviceUtil) { + deviceUtil(deviceUtil, options, coreCtx) { + const decryptStub = coreCtx.localhostBigIp.mockDecryptSecret(); + decryptStub.callsFake((...secrets) => secrets.map((s) => s.slice(3)).join('')); + + const encryptStub = coreCtx.localhostBigIp.mockEncryptSecret({ optionally: true, replyTimes: Infinity }); + encryptStub.encrypt.stub.callsFake((reqBody) => [200, { + secret: `$M$${reqBody.secret}` + }]); + + const getDeviceType = coreCtx.localhostBigIp.mockDeviceType(); + const ctx = { - decryptSecret: sinon.stub(deviceUtil, 'decryptSecret'), - encryptSecret: sinon.stub(deviceUtil, 'encryptSecret'), - getDeviceType: sinon.stub(deviceUtil, 'getDeviceType') + decrypt: decryptStub, + encrypt: encryptStub, + getDeviceType }; - ctx.decryptSecret.callsFake((data) => Promise.resolve(data.slice(3))); - ctx.encryptSecret.callsFake((data) => Promise.resolve(`$M$${data}`)); - ctx.getDeviceType.callsFake(() => Promise.resolve(constants.DEVICE_TYPE.BIG_IP)); + return ctx; }, @@ -291,66 +376,6 @@ const _module = module.exports = { return stub; }, - /** - * Stub modules for iHealthPoller - * - * @param {object} modules - modules to stub - * @param {module} [modules.ihealthUtil] - iHealth Util - * - * @returns {iHealthPollerStubCtx} stubs for iHealthPoller related modules - */ - iHealthPoller(modules) { - const ctx = {}; - if (modules.ihealthUtil) { - ctx.ihealthUtil = _module.ihealthUtil(modules.ihealthUtil); - } - return ctx; - }, - - /** - * Stub for iHealth utils - * - * @param {module} ihealthUtil - module - * - * @returns {ihealthUtilStubCtx} stub context - */ - ihealthUtil(ihealthUtil) { - const qkviewFile = 'qkviewFile'; - const qkviewReportExample = { - diagnostics: [], - system_information: { - hostname: 'localhost.localdomain' - } - }; - const qkviewURI = 'https://ihealth-api.f5.com/qkview-analyzer/api/qkviews/0000000'; - - const ctx = { - DeviceAPI: { - removeFile: sinon.stub(ihealthUtil.DeviceAPI.prototype, 'removeFile') - }, - IHealthManager: { - constructor: sinon.spy(ihealthUtil, 'IHealthManager'), - fetchQkviewDiagnostics: sinon.stub(ihealthUtil.IHealthManager.prototype, 'fetchQkviewDiagnostics'), - isQkviewReportReady: sinon.stub(ihealthUtil.IHealthManager.prototype, 'isQkviewReportReady'), - initialize: sinon.stub(ihealthUtil.IHealthManager.prototype, 'initialize'), - uploadQkview: sinon.stub(ihealthUtil.IHealthManager.prototype, 'uploadQkview') - }, - QkviewManager: { - constructor: sinon.spy(ihealthUtil, 'QkviewManager'), - initialize: sinon.stub(ihealthUtil.QkviewManager.prototype, 'initialize'), - process: sinon.stub(ihealthUtil.QkviewManager.prototype, 'process') - } - }; - ctx.DeviceAPI.removeFile.resolves(); - ctx.IHealthManager.fetchQkviewDiagnostics.callsFake(() => Promise.resolve(deepCopy(qkviewReportExample))); - ctx.IHealthManager.isQkviewReportReady.resolves(true); - ctx.IHealthManager.initialize.callsFake(function init() { return Promise.resolve(this); }); - ctx.IHealthManager.uploadQkview.callsFake(() => Promise.resolve(deepCopy(qkviewURI))); - ctx.QkviewManager.initialize.callsFake(function init() { return Promise.resolve(this); }); - ctx.QkviewManager.process.callsFake(() => Promise.resolve(deepCopy(qkviewFile))); - return ctx; - }, - /** * Stub for Logger * @@ -372,6 +397,7 @@ const _module = module.exports = { const setLogLevelOrigin = logger.setLogLevel; // deeply tied to current implementation const ctx = { + logger, messages: { all: [] }, @@ -419,51 +445,74 @@ const _module = module.exports = { }, /** - * Stub for Persistent Storage with RestStorage as backend + * Stub for ResourceMonitor * - * @param {module} persistentStorage - module + * @param {module} resourceMonitor - module + * @param {object} [options] * - * @returns {PersistentStorageStubCtx} stub context + * @returns {ResourceMonitorUtilsStubCtx} stub context */ - persistentStorage(persistentStorage) { - const restWorker = { - loadState: sinon.stub(), - saveState: sinon.stub() + resourceMonitorUtils(resourceMonitorUtils) { + const ctx = { + appMemoryUsage: sinon.stub(process, 'memoryUsage'), + osAvailableMem: sinon.stub(resourceMonitorUtils, 'osAvailableMem') }; + ctx.appMemoryUsage.external = 100; + ctx.appMemoryUsage.heapTotal = 101; + ctx.appMemoryUsage.heapUsed = 90; + ctx.appMemoryUsage.rss = 300; + ctx.appMemoryUsage.callsFake(() => ({ + external: ctx.appMemoryUsage.external, + heapTotal: ctx.appMemoryUsage.heapTotal, + heapUsed: ctx.appMemoryUsage.heapUsed, + rss: ctx.appMemoryUsage.rss + })); + + ctx.osAvailableMem.free = 100; + ctx.osAvailableMem.callsFake(() => ctx.osAvailableMem.free); + return ctx; + }, + + /** + * Stub for Persistent Storage with RestStorage as backend + * + * @returns {RestWorkerStubCtx} stub context + */ + restWorker() { const ctx = { loadCbAfter: null, loadCbBefore: null, // loadData - should be set explicitly loadError: null, - loadState: { _data_: {} }, - restWorker, + loadState: sinon.stub(), + loadStateData: { _data_: {} }, saveCbAfter: null, saveCbBefore: null, saveError: null, savedData: null, + saveState: sinon.stub(), savedState: null, - savedStateParse: true, - storage: sinon.stub(persistentStorage.persistentStorage, 'storage') + savedStateParse: true }; - restWorker.loadState.callsFake((first, cb) => { + ctx.loadState.callsFake((first, cb) => { if (ctx.loadCbBefore) { ctx.loadCbBefore(ctx, first, cb); } if (Object.prototype.hasOwnProperty.call(ctx, 'loadData')) { - ctx.loadState = { _data_: JSON.stringify(ctx.loadData) }; + ctx.loadStateData = { _data_: JSON.stringify(ctx.loadData) }; delete ctx.loadData; } - cb(ctx.loadError, ctx.loadState); + cb(ctx.loadError, ctx.loadStateData); if (ctx.loadCbAfter) { ctx.loadCbAfter(ctx, first, cb); } }); - restWorker.saveState.callsFake((first, state, cb) => { + ctx.saveState.callsFake((first, state, cb) => { if (ctx.saveCbBefore) { ctx.saveCbBefore(ctx, first, state, cb); } // override to be able to load it again - ctx.loadState = state; + ctx.loadStateData = state; ctx.savedState = deepCopy(state); if (ctx.savedState._data_ && ctx.savedStateParse) { ctx.savedState._data_ = JSON.parse(ctx.savedState._data_); @@ -474,54 +523,28 @@ const _module = module.exports = { ctx.saveCbAfter(ctx, first, state, cb); } }); - ctx.storage.value(new persistentStorage.RestStorage(restWorker)); return ctx; }, /** - * Stub for ResourceMonitor + * Stub for Storage Service * - * @param {module} resourceMonitor - module + * @param {object} StorageService - class + * @param {object} options + * @param {CoreStubCtx} coreCtx * - * @returns {ResourceMonitorUtilsStubCtx} stub context + * @returns {StorageStubCtx} stub context */ - resourceMonitorUtils(resourceMonitorUtils) { + storage(StorageService, options, coreCtx) { const ctx = { - appMemoryUsage: sinon.stub(resourceMonitorUtils, 'appMemoryUsage'), - osAvailableMem: sinon.stub(resourceMonitorUtils, 'osAvailableMem') + service: new StorageService(), + restWorker: _module.restWorker() }; - ctx.appMemoryUsage.external = 100; - ctx.appMemoryUsage.heapTotal = 101; - ctx.appMemoryUsage.heapUsed = 90; - ctx.appMemoryUsage.rss = 300; - ctx.appMemoryUsage.callsFake(() => ({ - external: ctx.appMemoryUsage.external, - heapTotal: ctx.appMemoryUsage.heapTotal, - heapUsed: ctx.appMemoryUsage.heapUsed, - rss: ctx.appMemoryUsage.rss - })); - ctx.osAvailableMem.free = 100; - ctx.osAvailableMem.callsFake(() => ctx.osAvailableMem.free); - return ctx; - }, + ctx.service.initialize( + coreCtx.appEvents.appEvents, ctx.restWorker + ); - /** - * Stub for TeemReporter - * - * @param {module} teemReporter - module - * - * @returns {TeemReporterStubCtx} stub context - */ - teemReporter(teemReporter) { - const ctx = { - declarations: [], - process: sinon.stub(teemReporter.TeemReporter.prototype, 'process') - }; - ctx.process.callsFake((declaration) => { - ctx.declarations.push(declaration); - return Promise.resolve(); - }); return ctx; }, @@ -529,25 +552,18 @@ const _module = module.exports = { * Stub for Tracer * * @param {module} tracer - module + * @param {object} options + * @param {CoreStubCtx} coreCtx * * @returns {TracerStubCtx} stub context */ - tracer(tracer) { + tracer(tracer, options, coreCtx) { const cwd = process.cwd(); const pathMap = {}; - const volume = new memfs.Volume(); - const virtualFS = memfs.createFsFromVolume(volume); - const promisifiedFS = promisifyNodeFsModule(virtualFS); + const emitter = new EventEmitter2(); this.eventEmitter(emitter); - sinon.stub(virtualFS, 'mkdir').callsFake(function (dirPath) { - volume.mkdirSync(dirPath, { recursive: true }); - return virtualFS.mkdir.wrappedMethod.apply(virtualFS, arguments); - }); - - volume.reset(); - let pendingWrites = 0; emitter.on('dec', () => emitter.emit('change', -1)); emitter.on('inc', () => emitter.emit('change', 1)); @@ -558,8 +574,6 @@ const _module = module.exports = { const ctx = { create: sinon.stub(tracer, 'create'), - fs: virtualFS, - promisifiedFS, waitForData() { if (!pendingWrites) { return Promise.resolve(); @@ -573,7 +587,7 @@ const _module = module.exports = { Object.defineProperties(ctx, { data: { get() { - const virtualPaths = volume.toJSON(); + const virtualPaths = coreCtx.utilMisc.fs.volume.toJSON(); Object.keys(virtualPaths).forEach((absolute) => { // parse data at first let data = virtualPaths[absolute]; @@ -598,7 +612,7 @@ const _module = module.exports = { } }); - ctx.create.callsFake((path, options) => { + ctx.create.callsFake((path, createOptions) => { if (!pathUtil.isAbsolute(path)) { const normPath = pathUtil.resolve( pathUtil.join(cwd, pathUtil.normalize(path)) @@ -606,19 +620,7 @@ const _module = module.exports = { pathMap[normPath] = pathMap[normPath] || []; pathMap[normPath].push(path); } - // - make copy to avoid modifications for origin options - // - copy it back to origin options to reflect changes - // reason: - // - caller POV: want to see all changes and don't want to see virtualFS ref - // - Tracer POV: want to keep 'optionsCopy' unmodified - const optionsCopy = Object.assign({}, options || {}); - optionsCopy.fs = promisifiedFS; - const inst = ctx.create.wrappedMethod.call(tracer, path, optionsCopy); - Object.assign(options || {}, optionsCopy); - if (options) { - delete options.fs; - } - return inst; + return ctx.create.wrappedMethod.call(tracer, path, createOptions); }); ctx.write.callsFake(function (data) { emitter.emit('inc'); @@ -672,6 +674,58 @@ const _module = module.exports = { nodeVersion: ctx.getRuntimeInfo.nodeVersion })); ctx.networkCheck.resolves(); + + ctx.fs = { + volume: new memfs.Volume() + }; + ctx.fs.fs = memfs.createFsFromVolume(ctx.fs.volume); + + const realFS = utilMisc.fs; + const originMethods = { + mkdir: ctx.fs.fs.mkdir + }; + ctx.fs.fs.mkdir = function customMkdir(dirPath) { + ctx.fs.volume.mkdirSync(pathUtil.dirname(dirPath), { recursive: true }); + return originMethods.mkdir.apply(ctx.fs.fs, arguments); + }; + + ['access', 'readdir', 'stat'].forEach((method) => { + originMethods[method] = ctx.fs.fs[method]; + ctx.fs.fs[method] = function customFN(fsPath) { + if (fsPath.includes('lib/consumers') + || fsPath.includes('unit/consumers') + || fsPath.includes('unit/dataPipeline/consumers')) { + return realFS[method].apply(realFS, arguments); + } + return originMethods[method].apply(ctx.fs.fs, arguments); + }; + }); + + // need to copy symbols first + const symb = Object.getOwnPropertySymbols(nodeFS.read).find((s) => /customPromisifyArgs/.test(s.toString())); + assert.isDefined(symb, 'should be able to find a symbol!'); + Object.entries(nodeFS).forEach(([key, fn]) => { + if (fn[symb]) { + ctx.fs.fs[key][symb] = fn[symb]; + } + }); + + ctx.fs.promise = (function promisifyNodeFsModule(fsModule) { + const newFsModule = Object.create(fsModule); + Object.keys(fsModule).forEach((key) => { + if (typeof fsModule[`${key}Sync`] !== 'undefined') { + newFsModule[key] = nodeUtil.promisify(fsModule[key]); + } + }); + return newFsModule; + }(ctx.fs.fs)); + + sinon.stub(ctx.fs.promise); + Object.values(ctx.fs.promise).forEach((fn) => fn.callThrough()); + + utilMisc.fs = ctx.fs.promise; + ctx.fs.volume.reset(); + return ctx; } }; @@ -691,12 +745,12 @@ _module.default = { coreStub(modules, options) { modules = Object.assign({}, modules); const srcMap = { + appEvents: 'src/lib/appEvents', configWorker: 'src/lib/config', deviceUtil: 'src/lib/utils/device', logger: 'src/lib/logger', - persistentStorage: 'src/lib/persistentStorage', resourceMonitorUtils: 'src/lib/resourceMonitor/utils', - teemReporter: 'src/lib/teemReporter', + storage: 'src/lib/storage', tracer: 'src/lib/utils/tracer', utilMisc: 'src/lib/utils/misc' }; @@ -726,65 +780,51 @@ _module.default = { }; /** - * @typedef ClockStubCtx - * @type {object} + * @typedef {object} AppEventsStubCtx + * @property {object} appEvents - instance of AppEvents + * @property {object} stub - sinon stub + */ +/** + * @typedef {object} ClockStubCtx * @property {function} clockForward - move clock forward * @property {object} [fakeClock] - sinon' fakeTimer object * @property {function} stopClockForward - stop fake clock activity * @property {object} stub - sinon stub */ /** - * @typedef ConfigWorkerStubCtx - * @type {EventEmitter2Ctx} + * @typedef {EventEmitter2Ctx} ConfigWorkerStubCtx * @property {Array} configs - list of emitted configs + * @property {ConfigWorker} configWorker - Config Worker instances * @property {sinon.spy} receivedSpy - spy for 'received' event * @property {sinon.spy} validationFailedSpy - spy for 'validationFailed' event * @property {sinon.spy} validationSucceedSpy - spy for 'validationSucceed' event */ /** - * @typedef CoreStubCtx - * @type {object} + * @typedef {object} CoreStubCtx * @property {ConfigWorkerStubCtx} configWorker - config worker stub + * @property {function} destroyServices - destroy services * @property {DeviceUtilStubCtx} deviceUtil - Device Util stub + * @property {BigIpRestApiMock} localhostBigIp - BigIpRestApiMock instance for localhost * @property {LoggerStubCtx} logger - Logger stub - * @property {PersistentStorageStubCtx} persistentStorage - Persistent Storage stub - * @property {TeemReporterStubCtx} teemReporter - Teem Reporter stub + * @property {function} startServices - start services + * @property {function} stopServices - stop services * @property {TracerStubCtx} tracer - Tracer stub * @property {UtilMiscStubCtx} utilMisc - Util Misc. stub */ /** - * @typedef DeviceUtilStubCtx - * @type {object} - * @property {object} decryptSecret - stub for decryptSecret - * @property {object} encryptSecret - stub for encryptSecret - * @property {object} getDeviceType - stub for getDeviceType + * @typedef {object} DeviceUtilStubCtx + * @property {MockNockStubBase} decrypt - stub for decryptSecrets + * @property {MockNockStubBase} encrypt - stub for encryptSecret + * @property {MockNockStubBase} getDeviceType - stub for getDeviceType */ /** - * @typedef EventEmitter2Ctx - * @type {object} + * @typedef {object} EventEmitter2Ctx * @property {Object} preExistingListeners - listeners to restore * @property {object} stub - sinon stub */ /** - * @typedef iHealthPollerStubCtx - * @type {object} - * @property {ihealthUtilStubCtx} ihealthUtil - iHealth Utils stubs - */ -/** - * @typedef ihealthUtilStubCtx - * @type {object} - * @property {object} IHealthManager - IHealthManager stubs - * @property {object} IHealthManager.fetchQkviewDiagnostics - stub for IHealthManager.prototype.fetchQkviewDiagnostics - * @property {object} IHealthManager.isQkviewReportReady - stub for IHealthManager.prototype.isQkviewReportReady - * @property {object} IHealthManager.initialize - stub for IHealthManager.prototype.initialize - * @property {object} IHealthManager.uploadQkview - stub for IHealthManager.prototype.uploadQkview - * @property {object} QkviewManager - IHealthManager stubs - * @property {object} QkviewManager.initialize - stub for QkviewManager.prototype.initialize - * @property {object} QkviewManager.process - stub for QkviewManager.prototype.process - */ -/** - * @typedef LoggerStubCtx - * @type {object} + * @typedef {object} LoggerStubCtx + * @property {Logger} logger - logger * @property {object} messages - logged messages * @property {Array} messages.all - all logged messages * @property {Array} messages.debug - debug messages @@ -799,41 +839,38 @@ _module.default = { * @property {object} proxy_erro - sinon stub for Logger.logger.error */ /** - * @typedef PersistentStorageStubCtx - * @type {object} + * @typedef {object} ResourceMonitorUtilsStubCtx + * @property {object} appMemoryUsage - stub for appMemoryUsage + * @property {number} appMemoryUsage.external - `external` value + * @property {number} appMemoryUsage.heapTotal - `heapTotal` value + * @property {number} appMemoryUsage.heapUsed - `heapUsed` value + * @property {number} appMemoryUsage.rss - `rss` value + * @property {object} osAvailableMem - stub for osAvailableMem + * @property {number} osAvailableMem.free - free memory value + */ +/** + * @typedef {object} RestWorkerStubCtx * @property {function} loadCbAfter - error to throw on attempt to load * @property {function} loadCbBefore - error to throw on attempt to load * @property {any} loadData - data to set to '_data_' property on attempt to load * @property {Error} loadError - error to return to callback passed on attempt to load - * @property {any} loadState - state to return on attempt to load + * @property {sinon.stub} loadState - stub for 'loadState' function + * @property {any} loadStateData - state to return on attempt to load * @property {object} restWorker - RestWorker stub * @property {function} saveCbAfter - error to throw on attempt to save * @property {function} saveCbBefore - error to throw on attempt to save * @property {Error} saveError - error to return to callback passed on attempt to save + * @property {sinon.stub} saveState - stub for 'saveState' function * @property {any} savedState - saved state on attempt to save (will override 'loadState') * @property {boolean} savedStateParse - parse '_data_' property of saved state if exist - * @property {object} storage - sinon stub for persistentStorage.persistentStorage.storage - */ -/** - * @typedef ResourceMonitorUtilsStubCtx - * @type {object} - * @property {object} appMemoryUsage - stub for appMemoryUsage - * @property {number} appMemoryUsage.external - `external` value - * @property {number} appMemoryUsage.heapTotal - `heapTotal` value - * @property {number} appMemoryUsage.heapUsed - `heapUsed` value - * @property {number} appMemoryUsage.rss - `rss` value - * @property {object} osAvailableMem - stub for osAvailableMem - * @property {number} osAvailableMem.free - free memory value */ /** - * @typedef TeemReporterStubCtx - * @type {object} - * @property {Array} declarations - list of processed declarations - * @property {object} process - sinon stub for TeemReporter.prototype.process + * @typedef {object} StorageStubCtx + * @property {StorageService} service - Storage Service instance + * @property {RestWorkerStubCtx} restWorker - Rest Worker stub */ /** - * @typedef TracerStubCtx - * @type {object} + * @typedef {object} TracerStubCtx * @property {Object>} data - data written to tracers * @property {object} fromConfig - sinon stub for tracer.fromConfig method * @property {number} pendingWrites - number of pending attempts to write data @@ -841,8 +878,11 @@ _module.default = { * @property {object} write - sinon stub for Tracer.prototype.write */ /** - * @typedef UtilMiscStubCtx - * @type {object} + * @typedef {object} UtilMiscStubCtx + * @property {object} fs - stub for FS + * @property {memfs.Volume} fs.volume + * @property {memfs.FS} fs.fs - virtual FS module + * @property {memfs.FS} fs.promise - promisified virtual FS module * @property {object} generateUuid - stub for generateUuid * @property {number} generateUuid.uuidCounter - counter value * @property {boolean} generateUuid.numbersOnly - numbers only diff --git a/test/unit/shared/tests/bigipConn.js b/test/unit/shared/tests/bigipConn.js new file mode 100644 index 00000000..3e8fdf55 --- /dev/null +++ b/test/unit/shared/tests/bigipConn.js @@ -0,0 +1,60 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const { deepCopy } = require('../util'); + +const testData = [ + { + value: {}, + error: /connection should be a non-empty collection/ + }, + { + value: { allowSelfSignedCert: null }, + error: /allowSelfSignedCert should be a boolean/ + }, + { + value: { allowSelfSignedCert: 1 }, + error: /allowSelfSignedCert should be a boolean/ + }, + { + value: { port: null }, + error: /port should be a safe number/ + }, + { + value: { port: 0 }, + error: /port should be > 0/ + }, + { + value: { port: 2 ** 16 + 1 }, + error: /port should be deepCopy(testData); diff --git a/test/unit/shared/tests/bigipCreds.js b/test/unit/shared/tests/bigipCreds.js new file mode 100644 index 00000000..baf39a8e --- /dev/null +++ b/test/unit/shared/tests/bigipCreds.js @@ -0,0 +1,118 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const { deepCopy } = require('../util'); + +const testDataLocalHost = [ + { + value: {}, + error: /credentials should be a non-empty collection/ + }, + { + value: { token: 10 }, + error: /token should be a string/ + }, + { + value: { username: 10 }, + error: /username should be a string/ + }, + { + value: { username: '' }, + error: /username should be a non-empty collection/ + }, + { + value: { username: 'test_user_1', passphrase: '' }, + error: /passphrase should be a non-empty collection/ + }, + { + value: { username: 'test_user_1', passphrase: 10 }, + error: /passphrase should be a string/ + }, + { + value: { token: 'token', username: 10 }, + error: /username should be a string/ + }, + { + value: { token: 'token', username: '' }, + error: /username should be a non-empty collection/ + }, + { + value: { token: 'token', username: 'test_user_1', passphrase: '' }, + error: /passphrase should be a non-empty collection/ + }, + { + value: { token: 'token', username: 'test_user_1', passphrase: 10 }, + error: /passphrase should be a string/ + } +]; +const testDataRemoteHost = [ + { + value: undefined, + error: /credentials should be an object/ + }, + { + value: {}, + error: /credentials should be a non-empty collection/ + }, + { + value: { token: null }, + error: /token should be a string/ + }, + { + value: { username: 10 }, + error: /username should be a string/ + }, + { + value: { username: '' }, + error: /username should be a non-empty collection/ + }, + { + value: { username: 'test_user_1' }, + error: /passphrase should be a string/ + }, + { + value: { username: 'test_user_1', passphrase: '' }, + error: /passphrase should be a non-empty collection/ + }, + { + value: { username: 'test_user_1', passphrase: 10 }, + error: /passphrase should be a string/ + }, + { + value: { username: 'test_user_1', passphrase: 'test_passphrase_1', token: null }, + error: /token should be a string/ + }, + { + value: { token: 'token', username: 10 }, + error: /username should be a string/ + }, + { + value: { token: 'token', username: '' }, + error: /username should be a non-empty collection/ + }, + { + value: { token: 'token', username: 'test_user_1', passphrase: '' }, + error: /passphrase should be a non-empty collection/ + }, + { + value: { token: 'token', username: 'test_user_1', passphrase: 10 }, + error: /passphrase should be a string/ + } +]; + +module.exports = (host) => deepCopy(host === 'localhost' ? testDataLocalHost : testDataRemoteHost); diff --git a/test/unit/shared/tests/httpProxy.js b/test/unit/shared/tests/httpProxy.js new file mode 100644 index 00000000..301569ea --- /dev/null +++ b/test/unit/shared/tests/httpProxy.js @@ -0,0 +1,86 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const { deepCopy } = require('../util'); + +const host = 'remoteproxyhost'; + +const testData = [ + { + value: {}, + error: /proxy should be a non-empty collection/ + }, + { + value: { connection: {} }, + error: /proxy.connection should be a non-empty collection/ + }, + { + value: { connection: { host }, credentials: {} }, + error: /credentials should be a non-empty collection/ + }, + { + value: { connection: { host }, credentials: { username: 10 } }, + error: /credentials.username should be a string/ + }, + { + value: { connection: { host }, credentials: { username: '' } }, + error: /credentials.username should be a non-empty collection/ + }, + { + value: { connection: { host }, credentials: { username: 'test_user_1', passphrase: '' } }, + error: /credentials.passphrase should be a non-empty collection/ + }, + { + value: { connection: { host }, credentials: { username: 'test_user_1', passphrase: 10 } }, + error: /credentials.passphrase should be a string/ + }, + { + value: { connection: { host, allowSelfSignedCert: null } }, + error: /connection.allowSelfSignedCert should be a boolean/ + }, + { + value: { connection: { host, allowSelfSignedCert: 1 } }, + error: /connection.allowSelfSignedCert should be a boolean/ + }, + { + value: { connection: { host, port: null } }, + error: /connection.port should be a safe number/ + }, + { + value: { connection: { host, port: 0 } }, + error: /connection.port should be > 0/ + }, + { + value: { connection: { host, port: 2 ** 16 + 1 } }, + error: /connection.port should be deepCopy(testData); diff --git a/test/unit/shared/tests/ihealthCreds.js b/test/unit/shared/tests/ihealthCreds.js new file mode 100644 index 00000000..b2bb8e0a --- /dev/null +++ b/test/unit/shared/tests/ihealthCreds.js @@ -0,0 +1,52 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const { deepCopy } = require('../util'); + +const testData = [ + { + value: undefined, + error: /credentials should be an object/ + }, + { + value: {}, + error: /credentials should be a non-empty collection/ + }, + { + value: { username: 10 }, + error: /username should be a string/ + }, + { + value: { username: '' }, + error: /username should be a non-empty collection/ + }, + { + value: { username: 'test_user_1' }, + error: /passphrase should be a string/ + }, + { + value: { username: 'test_user_1', passphrase: '' }, + error: /passphrase should be a non-empty collection/ + }, + { + value: { username: 'test_user_1', passphrase: 10 }, + error: /passphrase should be a string/ + } +]; + +module.exports = () => deepCopy(testData); diff --git a/test/unit/shared/util.js b/test/unit/shared/util.js index 9f7a5f32..7d87679d 100644 --- a/test/unit/shared/util.js +++ b/test/unit/shared/util.js @@ -18,9 +18,11 @@ const assignDefaults = require('lodash/defaultsDeep'); const deepCopy = require('lodash/cloneDeep'); +const EventEmitter2 = require('eventemitter2'); const fsUtil = require('fs'); const nock = require('nock'); const pathUtil = require('path'); +const requestLib = require('request'); const sinon = require('sinon'); const urllib = require('url'); @@ -29,6 +31,15 @@ const systemPollerData = require('../../../examples/output/system_poller/output. const avrData = require('../consumers/data/avrData.json'); const ltmData = require('../consumers/data/ltmData.json'); +const SMOKE_TEST_SYMB = Symbol.for('SMOKE_TEST'); +const SMOKE_TESTING_ENABLED = !!process.env.SMOKE_TESTING; + +function responseWrapper(code, response) { + return function inner() { + const ret = response.apply(this, arguments); + return Array.isArray(ret) ? ret : [code || 200, ret]; + }; +} function MockRestOperation(opts) { opts = opts || {}; this.method = opts.method || 'GET'; @@ -38,21 +49,82 @@ function MockRestOperation(opts) { this.statusCode = null; this.uri = {}; this.uri.pathname = opts.uri; + + this.getBody = sinon.stub().callsFake(() => this.body); + this.setBody = sinon.stub().callsFake((body) => { this.body = body; }); + this.getContentType = sinon.stub().callsFake(() => this.contentType); + this.setContentType = sinon.stub().callsFake((ct) => { this.contentType = ct; }); + this.getHeaders = sinon.stub().callsFake(() => this.headers); + this.setHeaders = sinon.stub().callsFake((headers) => { this.headers = headers; }); + this.getMethod = sinon.stub().callsFake(() => this.method); + this.setMethod = sinon.stub().callsFake((method) => { this.method = method; }); + this.getStatusCode = sinon.stub().callsFake(() => this.statusCode); + this.setStatusCode = sinon.stub().callsFake((code) => { this.statusCode = code; }); + this.getUri = sinon.stub().callsFake(() => this.uri); + this.complete = sinon.stub().callsFake(() => {}); } -MockRestOperation.prototype.getBody = function () { return this.body; }; -MockRestOperation.prototype.setBody = function (body) { this.body = body; }; -MockRestOperation.prototype.getContentType = function () { return this.contentType; }; -MockRestOperation.prototype.setContentType = function (ct) { this.contentType = ct; }; -MockRestOperation.prototype.getHeaders = function () { return this.headers; }; -MockRestOperation.prototype.setHeaders = function (headers) { this.headers = headers; }; -MockRestOperation.prototype.getMethod = function () { return this.method; }; -MockRestOperation.prototype.setMethod = function (method) { this.method = method; }; -MockRestOperation.prototype.getStatusCode = function () { return this.statusCode; }; -MockRestOperation.prototype.setStatusCode = function (code) { this.statusCode = code; }; -MockRestOperation.prototype.getUri = function () { return this.uri; }; -MockRestOperation.prototype.complete = function () { }; MockRestOperation.prototype.parseAndSetURI = function (uri) { this.uri = module.exports.parseURL(uri); }; +/** + * TCP Socket Class Mock + */ +class TCPSocketMock extends EventEmitter2 { + constructor() { + super(); + sinon.spy(this, 'destroy'); + } + + destroy() { + setImmediate(() => this.emit('destroyMock', this)); + } +} + +/** + * TCP Server Class Mock + */ +class TCPServerMock extends EventEmitter2 { + constructor() { + super(); + sinon.spy(this, 'close'); + sinon.spy(this, 'listen'); + } + + setInitArgs(opts) { + this.opts = opts; + } + + listen() { + setImmediate(() => this.emit('listenMock', this, Array.from(arguments))); + } + + close() { + setImmediate(() => this.emit('closeMock', this, Array.from(arguments))); + } +} + +/** + * UDP Server Class Mock + */ +class UDPServerMock extends EventEmitter2 { + constructor() { + super(); + sinon.spy(this, 'close'); + sinon.spy(this, 'bind'); + } + + setInitArgs(opts) { + this.opts = opts; + } + + bind() { + setImmediate(() => this.emit('bindMock', this, Array.from(arguments))); + } + + close() { + setImmediate(() => this.emit('closeMock', this, Array.from(arguments))); + } +} + /** * Logger mock object * @@ -89,6 +161,9 @@ const _module = module.exports = { MockRestOperation, MockLogger, MockTracer, + TCPServerMock, + TCPSocketMock, + UDPServerMock, /** * Assign defaults to object (uses lodash.defaultsDeep under the hood) @@ -285,20 +360,33 @@ const _module = module.exports = { }; } } - apiMock.reply(endpointMock.code || 200, response, endpointMock.responseHeaders); + if (typeof response === 'function') { + apiMock.reply(responseWrapper(endpointMock.code, response)); + } else { + apiMock.reply(endpointMock.code || 200, response, endpointMock.responseHeaders); + } }); }, + /** + * Remove all nock interceptors + * + * @param {Object} [nockInstance] - instance of nock library + */ + nockCleanup(nockInstance = nock) { + nockInstance.abortPendingRequests(); + nockInstance.cleanAll(); + }, + /** * Check if nock has unused mocks and raise assertion error if so * - * @param {Object} nockInstance - instance of nock library + * @param {Object} [nockInstance] - instance of nock library */ - checkNockActiveMocks(nockInstance) { - const activeMocks = nockInstance.activeMocks().join('\n'); + checkNockActiveMocks(nockInstance = nock) { assert.ok( - activeMocks.length === 0, - `nock should have no active mocks after the test, instead mocks are still active:\n${activeMocks}\n` + nockInstance.isDone(), + `nock should have no active mocks after the test, instead mocks are still active:\n${nockInstance.activeMocks().join('\n')}\n` ); }, @@ -312,7 +400,7 @@ const _module = module.exports = { */ getSpoiledDataValidator() { const getValidator = (idx, copy, src) => () => { - assert.deepStrictEqual(src, copy, `Original data at index ${idx} was spoiled...`); + assert.deepStrictEqual(src, copy, `Original data at index ${idx} unexpectedly mutated...`); return true; }; const validators = []; @@ -328,9 +416,6 @@ const _module = module.exports = { * @returns {Function(url)} function to parse URL into URL object */ parseURL: (function () { - if (process.versions.node.startsWith('4.')) { - return urllib.parse; - } return (url) => new urllib.URL(url); }()), @@ -415,6 +500,30 @@ const _module = module.exports = { return loadedFiles; }, + /** + * @returns {object} 'request' spies + */ + requestSpies() { + const ret = {}; + ['del', 'delete', 'get', 'head', 'options', 'post', 'put', 'patch'].forEach((verb) => { + ret[verb] = sinon.spy(requestLib, verb); + }); + return ret; + }, + + checkRequestSpies(spies, props) { + Object.entries(spies).forEach(([key, spy]) => { + if (spy.callCount !== 0) { + spy.args.forEach((args) => { + Object.entries(props).forEach(([name, expected]) => { + const actual = args[0][name]; + assert.deepStrictEqual(actual, expected, `request.${key} should use ${name} = ${expected}, got ${actual}`); + }); + }); + } + }); + }, + /** * Sleep for N ms. * @@ -424,6 +533,28 @@ const _module = module.exports = { return new Promise((resolve) => { setTimeout(resolve, sleepTime); }); }, + smokeTests: { + /** + * Remove SMOKE_TEST_SYMB from the collection + * + * @param {any[]} collection + */ + filter(collection) { + return collection.filter((item) => item !== SMOKE_TEST_SYMB); + }, + + /** + * Ignore value if smoke testing enabled + * + * @param {any} value + * + * @returns {SMOKE_TEST_SYMB | any} + */ + ignore(value) { + return SMOKE_TESTING_ENABLED ? SMOKE_TEST_SYMB : value; + } + }, + /** * Sort all arrays in data * @@ -444,11 +575,17 @@ const _module = module.exports = { * * @param {function} cb - callback to call (async or sync) * @param {number} [delay=0] - delay before next call + * @param {boolean} [ignoreError = false] - ignore uncaught errors * * @returns {Promise} resolved once `cb` returned true. Has `.cancel()` method * to cancel and reject promise */ - waitTill(cb, delay) { + waitTill(cb, delay = 0, ignoreError = false) { + if (typeof arguments[1] === 'boolean') { + ignoreError = delay; + delay = 0; + } + let timeoutID; let promiseReject; const promise = new Promise((resolve, reject) => { @@ -467,10 +604,10 @@ const _module = module.exports = { return !ret && !!promiseReject; }, (err) => { - if (promiseReject) { + if (!ignoreError && promiseReject) { promiseReject(err); } - return false; + return ignoreError; } ) .then((keep) => { @@ -481,7 +618,7 @@ const _module = module.exports = { } }); }, - delay || 0 + delay ); }()); }); diff --git a/test/unit/storage/serviceTests.js b/test/unit/storage/serviceTests.js new file mode 100644 index 00000000..8605dfd6 --- /dev/null +++ b/test/unit/storage/serviceTests.js @@ -0,0 +1,207 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtils = require('../shared/util'); + +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); +const StorageService = sourceCode('src/lib/storage'); + +moduleCache.remember(); + +describe('Storage Service', () => { + let coreStub; + let emitter; + let storageService; + let restWorkerStub; + + function getValue(key) { + return new Promise((resolve, reject) => { + emitter.emitAsync('storage.get', key, (error, value) => { + if (error) { + reject(error); + } else { + resolve(value); + } + }); + }); + } + + function setValue(key, value, useCallback = true) { + return new Promise((resolve, reject) => { + const callback = !useCallback ? null : (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }; + emitter.emitAsync('storage.set', key, value, callback); + + if (!callback) { + resolve(); + } + }); + } + + function removeValue(key, useCallback = true) { + return new Promise((resolve, reject) => { + const callback = !useCallback ? null : (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }; + emitter.emitAsync('storage.remove', key, callback); + + if (!callback) { + resolve(); + } + }); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ appEvents: true, logger: true }); + + restWorkerStub = stubs.restWorker(); + + emitter = new SafeEventEmitter(); + storageService = new StorageService(); + storageService.initialize(coreStub.appEvents.appEvents, restWorkerStub); + + coreStub.appEvents.appEvents.register(emitter, 'test', ['storage.get', 'storage.set', 'storage.remove']); + }); + + afterEach(async () => { + await coreStub.destroyServices(); + await storageService.destroy(); + assert.isTrue(storageService.isDestroyed()); + + sinon.restore(); + }); + + it('should restart when unable to load storage data', async () => { + restWorkerStub.loadState + .onFirstCall() + .callsFake((first, cb) => { + cb(new Error('expected error')); + }); + + await storageService.restart({ attempts: 3 }); + assert.isTrue(storageService.isRunning()); + + assert.includeMatch( + coreStub.logger.messages.all, + /Unable to load application state from the storage/ + ); + }); + + it('should get/set/remove data', async () => { + await storageService.start(); + assert.isUndefined(await getValue('test')); + + await setValue('test', 'value'); + assert.deepStrictEqual(await getValue('test'), 'value'); + + await setValue('test', { a: { b: { c: 10 } } }); + assert.deepStrictEqual(await getValue('test.a.b.c'), 10); + assert.deepStrictEqual(await getValue(['test', 'a', 'b']), { c: 10 }); + + await removeValue('test.b.c'); + await setValue('test', { a: { b: {} } }); + }); + + it('should return error when unable to get data', async () => { + await storageService.start(); + await assert.isRejected(getValue(['test', 10]), /should be a string/); + await assert.isRejected(getValue(undefined), /should be a string/); + await assert.isRejected(getValue(10), /should be a string/); + }); + + it('should return error when unable to set data', async () => { + await storageService.start(); + await assert.isRejected(setValue(['test', 10], 10), /should be a string/); + await assert.isRejected(setValue(undefined, 10), /should be a string/); + await assert.isRejected(setValue(10, 10), /should be a string/); + }); + + it('should return error when unable to remove data', async () => { + await storageService.start(); + await assert.isRejected(removeValue(['test', 10]), /should be a string/); + await assert.isRejected(removeValue(undefined), /should be a string/); + await assert.isRejected(removeValue(10), /should be a string/); + }); + + it('should not fail when no callback passed to storage.set', async () => { + await storageService.start(); + + restWorkerStub.saveState.callsFake((first, state, cb) => cb(new Error('expected save error'))); + + await setValue('test', 10, false); + assert.deepStrictEqual(await getValue('test'), 10); + }); + + it('should not fail when no callback passed to storage.remove', async () => { + await storageService.start(); + + restWorkerStub.saveState.callsFake((first, state, cb) => cb(new Error('expected remove error'))); + + await removeValue('test', false); + }); + + it('should unregister listeners once stopped', async () => { + await storageService.start(); + + await setValue('test', 'value'); + assert.deepStrictEqual(await getValue('test'), 'value'); + + await storageService.destroy(); + + let done = false; + setValue('test2', 'value') + .then(() => { + done = true; + }); + + await testUtils.sleep(500); + assert.isFalse(done); + }); + + it('should register listeners once restarted', async () => { + await storageService.start(); + + await setValue('test', 'value'); + assert.deepStrictEqual(await getValue('test'), 'value'); + + await storageService.restart(); + + assert.deepStrictEqual(await getValue('test'), 'value'); + }); +}); diff --git a/test/unit/storage/storageTests.js b/test/unit/storage/storageTests.js new file mode 100644 index 00000000..f16da095 --- /dev/null +++ b/test/unit/storage/storageTests.js @@ -0,0 +1,475 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const deepCopy = require('../shared/util').deepCopy; +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); + +const Storage = sourceCode('src/lib/storage/storage'); + +moduleCache.remember(); + +describe('Storage Service / Storage', () => { + let storageInst; + let restWorkerStub; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + const coreStub = stubs.default.coreStub({ logger: true }); + restWorkerStub = stubs.restWorker(); + storageInst = new Storage(restWorkerStub, coreStub.logger.logger); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('.get()', () => { + beforeEach(() => { + restWorkerStub.loadData = { + somekey: [1, 2, 3, 4, 5] + }; + }); + + it('should return undefined when key does not exist', async () => { + await storageInst.load(); + const value = await storageInst.get('somekey2'); + + assert.isUndefined(value); + }); + + it('should return cached data copy on attempt to \'get\' it', async () => { + await storageInst.load(); + const value = await storageInst.get('somekey'); + + value.push(6); + const valueCopy = await storageInst.get('somekey'); + + assert.deepStrictEqual(valueCopy, [1, 2, 3, 4, 5]); + }); + + it('should return data (one level key)', async () => { + await storageInst.load(); + const value = await storageInst.get('somekey'); + + assert.deepStrictEqual(value, [1, 2, 3, 4, 5]); + }); + + it('should return data (multi level key)', async () => { + await storageInst.load(); + const value = await storageInst.get('somekey[1]'); + + assert.deepStrictEqual(value, 2); + }); + + it('should return data (multi level key as array)', async () => { + restWorkerStub.loadData = { + somekey: { + 'key.with.dot': 10 + } + }; + await storageInst.load(); + assert.deepStrictEqual( + await storageInst.get(['somekey', 'key.with.dot']), + 10 + ); + assert.deepStrictEqual( + await storageInst.get('somekey[\'key.with.dot\']'), + 10 + ); + }); + + it('should throw error on invalid key', async () => { + await assert.isRejected(storageInst.get(10), /should be a string/); + await assert.isRejected(storageInst.get(['test', 10]), /should be a string/); + await assert.isRejected(storageInst.get(undefined), /should be a string/); + }); + }); + + describe('.set()', () => { + it('should make data copy on attempt to \'save\' it', async () => { + await storageInst.load(); + + const data = [1]; + const promise = storageInst.set('somekey', data); + data.push(2); + + await promise; + + const value = await storageInst.get('somekey'); + assert.deepStrictEqual(value, [1]); + }); + + it('should set data (one level key)', async () => { + await storageInst.load(); + await storageInst.set('somekey', 1); + const value = await storageInst.get('somekey'); + + assert.deepStrictEqual(value, 1); + }); + + it('should set data (multi level key)', async () => { + await storageInst.load(); + await storageInst.set('somekey.first.second', 10); + const value = await storageInst.get('somekey'); + + assert.deepStrictEqual(value, { first: { second: 10 } }); + }); + + it('should set data (multi level key as array)', async () => { + await storageInst.load(); + await storageInst.set(['somekey', 'first', 'second'], 10); + const value = await storageInst.get('somekey'); + + assert.deepStrictEqual(value, { first: { second: 10 } }); + }); + + it('should set data (multiple requests)', async () => { + restWorkerStub.loadData = {}; + await storageInst.load(); + + await Promise.all([ + storageInst.set('a.a', 10), + storageInst.set('a.b', 20) + ]); + + const results = await Promise.all([ + storageInst.get('a.a'), + storageInst.get('a.b') + ]); + + assert.deepStrictEqual(results, [10, 20]); + + const value = await storageInst.get('a'); + assert.deepStrictEqual(value, { a: 10, b: 20 }); + }); + + it('should overwrite data for existing key (multiple requests)', () => { + restWorkerStub.loadData = {}; + let counter = 0; + const getAndSet = () => storageInst.get('data') + .then((value) => { + if (typeof value === 'undefined') { + value = {}; + } + value[counter] = counter; + counter += 1; + return storageInst.set('data', value); + }); + return Promise.all([getAndSet(), getAndSet()]) + .then(() => storageInst.get('data')) + .then((data) => { + assert.lengthOf(Object.keys(data), 1); + }); + }); + + it('should throw error on invalid key', () => { + assert.throws(() => storageInst.set(10, 10), /should be a string/); + assert.throws(() => storageInst.set(['test', 10], 10), /should be a string/); + assert.throws(() => storageInst.set(undefined, 10), /should be a string/); + }); + }); + + describe('.remove()', () => { + it('should remove data (one level key)', async () => { + await storageInst.load(); + await storageInst.set('somekey.first.second', 10); + + const value = await storageInst.get('somekey'); + assert.deepStrictEqual(value, { first: { second: 10 } }); + + await storageInst.remove('somekey'); + + const valueCopy = await storageInst.get('somekey'); + assert.isUndefined(valueCopy); + }); + + it('should remove data (multi level key)', async () => { + await storageInst.load(); + await storageInst.set('somekey.first.second', 10); + + const value = await storageInst.get('somekey'); + assert.deepStrictEqual(value, { first: { second: 10 } }); + + await storageInst.remove('somekey.first.second'); + + const valueCopy = await storageInst.get('somekey'); + assert.deepStrictEqual(valueCopy, { first: { } }); + }); + + it('should remove data (multi level key as array)', async () => { + await storageInst.load(); + await storageInst.set('somekey.first.second', 10); + + const value = await storageInst.get('somekey'); + assert.deepStrictEqual(value, { first: { second: 10 } }); + + await storageInst.remove(['somekey', 'first', 'second']); + + const valueCopy = await storageInst.get('somekey'); + assert.deepStrictEqual(valueCopy, { first: { } }); + }); + + it('should not error when removing non-existent key', async () => { + await storageInst.load(); + assert.isUndefined(await storageInst.get('key')); + + await storageInst.remove('key'); + assert.isUndefined(await storageInst.get('key')); + }); + + it('should throw error on invalid key', async () => { + await assert.isRejected(storageInst.remove(10), /should be a string/); + await assert.isRejected(storageInst.remove(['test', 10]), /should be a string/); + await assert.isRejected(storageInst.remove(undefined), /should be a string/); + }); + }); + + describe('.load()', () => { + it('should fail to load when restWorker returns error', async () => { + restWorkerStub.loadError = new Error('loadStateError'); + await assert.isRejected(storageInst.load(), /loadStateError/); + }); + + it('should fail to load when restWorker throws error', async () => { + restWorkerStub.loadCbBefore = () => { throw new Error('loadStateError'); }; + await assert.isRejected(storageInst.load(), /loadStateError/); + }); + + it('should not fail when _data_ is null', async () => { + restWorkerStub.loadData = null; + await storageInst.load(); + }); + + it('should not fail when state is null', async () => { + restWorkerStub.loadStateData = null; + await storageInst.load(); + }); + + it('should not fail when state has unknown structure', async () => { + restWorkerStub.loadStateData = { key: 'value' }; + await storageInst.load(); + + assert.isUndefined(await storageInst.get('key')); + }); + + it('should load pre-existing state (old-version)', async () => { + restWorkerStub.loadStateData = { + config: { + key: 'somedata' + } + }; + await storageInst.load(); + assert.deepStrictEqual(await storageInst.get('config.key'), 'somedata'); + }); + + it('should load pre-existing state (new-version)', async () => { + restWorkerStub.loadStateData = { + _data_: { + somekey: 'somedata' + } + }; + await storageInst.load(); + assert.deepStrictEqual(await storageInst.get('somekey'), 'somedata'); + }); + }); + + describe('.save()', () => { + it('should fail to save when restWorker returns error', async () => { + restWorkerStub.saveError = new Error('saveStateError'); + await assert.isRejected( + storageInst.load() + .then(() => storageInst.save()), + /saveStateError/ + ); + }); + + it('should fail to save when restWorker throws error', async () => { + restWorkerStub.saveCbBefore = () => { throw new Error('saveStateError'); }; + await assert.isRejected(storageInst.save(), /saveStateError/); + }); + + it('should save loaded state', async () => { + await storageInst.load(); + await storageInst.save(); + + assert.deepStrictEqual(restWorkerStub.savedState, { _data_: { } }); + }); + + it('should queue \'save\' operation if current \'save\' in progress', async () => { + restWorkerStub.saveCbBefore = () => { + // trigger next 'save' op and it will be queued + // because we are in the middle of prev. 'save' op. + storageInst.save(); + delete restWorkerStub.saveCbBefore; + }; + await storageInst.load(); // save #1 + await storageInst.save(); + + assert.strictEqual(restWorkerStub.saveState.callCount, 2); + }); + + it('should fail to save when unable to copy data', async () => { + storageInst.storage._cache = { + _data_: {} + }; + storageInst.storage._cache._data_.cache = storageInst.storage._cache; + await assert.isRejected(storageInst.save(), /Converting circular structure to JSON/); + }); + }); + + describe('.load() & .save() mix', () => { + it('should preserve service properties on load and save', async () => { + const expectedState = { + _data_: { + somekey: 'somedata' + }, + sp1: 200, + sp2: 300 + }; + const loadState = { + _data_: { + somekey: 'somedata' + }, + sp1: 100, + sp2: 200 + }; + restWorkerStub.loadStateData = deepCopy(loadState); + restWorkerStub.saveCbBefore = (ctx, first, state) => { + // sometimes RestWorker backend sets some data required for further work + state.sp1 = 200; + state.sp2 = 300; + }; + await storageInst.load(); + await storageInst.save(); + + assert.deepStrictEqual(restWorkerStub.savedState, expectedState); + }); + + it('should save only once in current event cycle', async () => { + await storageInst.load(); + await Promise.all([ + storageInst.set('somekey', 1), // #1 + storageInst.set('somekey', 2), // #2, overrides #1 + storageInst.set('somekey', 3), // #3, overrides #2 + storageInst.set('somekey', 'expectedValue') // #4, overrides #3 + ]); + + const value = await storageInst.get('somekey'); + + assert.strictEqual(value, 'expectedValue'); + assert.strictEqual(restWorkerStub.saveState.callCount, 1); + }); + + it('should load only once in current event cycle', async () => { + await storageInst.load(); // load #1 + await Promise.all([ // load #2 + storageInst.load(), + storageInst.load(), + storageInst.load(), + storageInst.load(), + storageInst.load() + ]); + await Promise.all([ // load #3 + storageInst.load(), + storageInst.load(), + storageInst.load(), + storageInst.load(), + storageInst.load() + ]); + + assert.strictEqual(restWorkerStub.loadState.callCount, 3); + }); + + it('should be able to save data after previous attempt', async () => { + await storageInst.load(); // load #1 + await Promise.all([ // save #1 + storageInst.set('somekey', 1), + storageInst.set('somekey', 2), + storageInst.set('somekey', 3), + storageInst.set('somekey', 4) + ]); + await Promise.all([ // save #2 + storageInst.set('somekey', 1), + storageInst.set('somekey', 2), + storageInst.set('somekey', 10), + storageInst.set('somekey', 'expectedValue') + ]); + + const value = await storageInst.get('somekey'); + + assert.strictEqual(value, 'expectedValue'); + assert.strictEqual(restWorkerStub.saveState.callCount, 2); + }); + + it('should be able to save / load in current cycle', async () => { + await storageInst.load(); // load #1 + await Promise.all([ // load #2, save #1 + storageInst.set('somekey1', 1), + storageInst.set('somekey2', 2), + storageInst.set('somekey3', 3), + storageInst.load(), + storageInst.set('somekey4', 4), + storageInst.load(), + storageInst.set('somekey5', 6), + storageInst.load(), + storageInst.set('somekey6', 1) + ]); + + assert.strictEqual(restWorkerStub.saveState.callCount, 1); + assert.strictEqual(restWorkerStub.loadState.callCount, 2); + }); + + it('should preserve load-save order', async () => { + const history = []; + restWorkerStub.loadCbBefore = () => history.push('load'); + restWorkerStub.saveCbBefore = () => history.push('save'); + storageInst.load(); // load #1 + storageInst.save(); // save #1 + storageInst.load(); // load #1 + storageInst.save(); // save #1 + + await storageInst.save(); // save #1 + + assert.deepStrictEqual(history, ['load', 'save']); + }); + + it('should preserve save-load order', async () => { + const history = []; + restWorkerStub.loadCbBefore = () => history.push('load'); + restWorkerStub.saveCbBefore = () => history.push('save'); + storageInst.save(); // load #1 + storageInst.load(); // save #1 + storageInst.save(); // load #1 + storageInst.load(); // save #1 + + await storageInst.load(); // save #1 + + assert.deepStrictEqual(history, ['save', 'load']); + }); + }); +}); diff --git a/test/unit/systemPoller/collectorTests.js b/test/unit/systemPoller/collectorTests.js new file mode 100644 index 00000000..ac6f5059 --- /dev/null +++ b/test/unit/systemPoller/collectorTests.js @@ -0,0 +1,459 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const Collector = sourceCode('src/lib/systemPoller/collector'); +const Loader = sourceCode('src/lib/systemPoller/loader'); +const properties = sourceCode('src/lib/systemPoller/properties'); + +moduleCache.remember(); + +describe('System Poller / Colletor', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + let bigip; + let coreStub; + let loader; + let logger; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub({ logger: true }); + logger = coreStub.logger.logger; + + bigip = new BigIpApiMock(localhost); + bigip.addPasswordlessUser(defaultUser); + + loader = new Loader(localhost, { + logger: logger.getChild('loader'), + workers: 5 + }); + await loader.auth(); + }); + + afterEach(() => { + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('invalid args', async () => { + assert.throws(() => new Collector(), /loader should be an instance of Loader/); + assert.throws(() => new Collector(loader), /properties should be an object/); + assert.throws(() => new Collector(loader, {}), /properties should be a non-empty collection/); + assert.throws(() => new Collector(loader, { test: 'value' }), /logger should be an instance of Logger/); + assert.throws(() => new Collector( + loader, + { test: 'value' }, + { logger, isCustom: 10 } + ), /isCustom should be a boolean/); + assert.throws(() => new Collector( + loader, + { test: 'value' }, + { logger, isCustom: true, workers: null } + ), /workers should be a safe number/); + assert.throws(() => new Collector( + loader, + { test: 'value' }, + { logger, isCustom: true, workers: 0 } + ), /workers should be >= 1, got 0/); + assert.throws(() => new Collector( + loader, + { test: 'value' }, + { logger, isCustom: true, workers: Number.MAX_SAFE_INTEGER + 1 } + ), /workers should be a safe number/); + }); + + it('should use default values', async () => { + const collector = new Collector( + loader, + { test: 'value' }, + { logger } + ); + assert.isFalse(collector.isCustom); + assert.deepStrictEqual(collector.workers, 1); + }); + + it('should use custom values', async () => { + const collector = new Collector( + loader, + { test: 'value' }, + { logger, isCustom: true, workers: 10 } + ); + assert.isTrue(collector.isCustom); + assert.deepStrictEqual(collector.workers, 10); + }); + }); + + it('should collect stats', async () => { + const path = '/mgmt/tm/ltm/virtual'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1', + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + destination: '/Common/172.16.100.17:53' + }, + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'vs_with_pool', + partition: 'Common', + fullPath: '/Common/vs_with_pool', + destination: '/Common/10.12.12.49:8443' + } + ] + }] + }); + + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path + } + }; + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 10 } + ); + + const results = await collector.collect(); + assert.isFalse(collector.isActive()); + assert.isEmpty(results.errors, 'should have no errors reported'); + assert.deepStrictEqual(results.stats, { + virtualServers: { + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + destination: '/Common/172.16.100.17:53' + }, + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'vs_with_pool', + partition: 'Common', + fullPath: '/Common/vs_with_pool', + destination: '/Common/10.12.12.49:8443' + } + ] + } + }); + }); + + it('should create empty object when no data', async () => { + const path = '/mgmt/tm/ltm/virtual'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1' + }] + }); + + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path + } + }; + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 10 } + ); + + const results = await collector.collect(); + assert.isFalse(collector.isActive()); + assert.isEmpty(results.errors, 'should have no errors reported'); + assert.deepStrictEqual(results.stats, { + virtualServers: { + items: [] + } + }); + }); + + it('should create empty object on HTTP error', async () => { + const path = '/mgmt/tm/ltm/virtual'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 3, + response: () => [404, 'Not found'] + }); + + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path + } + }; + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 10 } + ); + + const results = await collector.collect(); + assert.isFalse(collector.isActive()); + assert.isNotEmpty(results.errors, 'should have error reported'); + assert.deepStrictEqual(results.stats, { + virtualServers: {} + }); + }); + + it('should stop non-active instance', async () => { + const path = '/mgmt/tm/ltm/virtual'; + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path + } + }; + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 10 } + ); + + assert.isFalse(collector.isActive()); + await collector.stop(); + assert.isFalse(collector.isActive()); + }); + + it('should stop active instance', async () => { + const path = '/mgmt/tm/ltm/virtual'; + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path + } + }; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: async () => { + await testUtil.sleep(500); + return [200, { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1', + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + destination: '/Common/172.16.100.17:53' + } + ] + }]; + } + }); + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 10 } + ); + + assert.isFalse(collector.isActive()); + + const resultsPromise = collector.collect(); + assert.isTrue(collector.isActive()); + + await testUtil.sleep(100); + assert.isTrue(collector.isActive()); + await collector.stop(); + + assert.isFalse(collector.isActive()); + + const results = await resultsPromise; + assert.isEmpty(results.errors, 'should have no errors reported'); + assert.deepStrictEqual(results.stats, { + virtualServers: { + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + destination: '/Common/172.16.100.17:53' + } + ] + } + }); + }); + + it('should stop active instance and ignore pending properties/tasks', async () => { + const endpoints = { + virtualServers: { + enable: true, + name: 'virtualServers', + path: '/mgmt/tm/ltm/virtual1' + }, + virtualServers2: { + enable: true, + name: 'virtualServers', + path: '/mgmt/tm/ltm/virtual2' + } + }; + let requestID; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: endpoints.virtualServers.path, + replyTimes: 1, + response: async () => { + requestID = '1'; + await testUtil.sleep(500); + return [200, { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1', + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default1', + partition: 'Common', + fullPath: '/Common/default1', + destination: '/Common/172.16.100.17:53' + } + ] + }]; + } + }); + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: endpoints.virtualServers2.path, + replyTimes: 1, + response: async () => { + requestID = '2'; + await testUtil.sleep(500); + return [200, { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1', + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default2', + partition: 'Common', + fullPath: '/Common/default2', + destination: '/Common/172.16.100.17:53' + } + ] + }]; + } + }); + + const customProps = properties.custom(endpoints, []); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector( + loader, + customProps.properties, + { logger, isCustom: true, workers: 1 } + ); + + assert.isFalse(collector.isActive()); + + const resultsPromise = collector.collect(); + assert.isTrue(collector.isActive()); + + await testUtil.sleep(100); + assert.isTrue(collector.isActive()); + + // multiple stops should be OK + await assert.isFulfilled(Promise.all([ + collector.stop(), + collector.stop(), + collector.stop() + ])); + + assert.isFalse(collector.isActive()); + + const results = await resultsPromise; + assert.isEmpty(results.errors, 'should have no errors reported'); + assert.deepStrictEqual(results.stats, { + virtualServers: { + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: `default${requestID}`, + partition: 'Common', + fullPath: `/Common/default${requestID}`, + destination: '/Common/172.16.100.17:53' + } + ] + } + }); + }); +}); diff --git a/test/unit/systemPoller/customEndpointsTests.js b/test/unit/systemPoller/customEndpointsTests.js new file mode 100644 index 00000000..e8ede499 --- /dev/null +++ b/test/unit/systemPoller/customEndpointsTests.js @@ -0,0 +1,125 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const customEndpointsTestsData = require('./data/customEndpointsTestsData'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const Collector = sourceCode('src/lib/systemPoller/collector'); +const Loader = sourceCode('src/lib/systemPoller/loader'); +const properties = sourceCode('src/lib/systemPoller/properties'); + +moduleCache.remember(); + +describe('System Poller / Colletor / Custom Endpoints', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + let coreStub; + let logger; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true }); + logger = coreStub.logger.logger; + }); + + afterEach(() => { + testUtil.nockCleanup(); + sinon.restore(); + }); + + const DEFAULT_TOTAL_ATTEMPTS = 3; + + const checkResponse = (endpointMock, response) => { + if (!response.kind && !endpointMock.skipCheckResponse) { + throw new Error(`Endpoint '${endpointMock.endpoint}' has no property 'kind' in response`); + } + }; + + const mockEndpoints = (endpoints) => { + const bigip = new BigIpApiMock(localhost); + bigip.addPasswordlessUser(defaultUser); + + endpoints.forEach((endpoint) => { + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: endpoint.method, + path: endpoint.endpoint, + replyTimes: Infinity, + reqBody: endpoint.request, + response: () => { + if (endpoint.skipCheckResponse !== true) { + checkResponse(endpoint, endpoint.response); + } + return [endpoint.code || 200, testUtil.deepCopy(endpoint.response)]; + } + }); + }); + }; + + Object.keys(customEndpointsTestsData).forEach((testSetKey) => { + const testSet = customEndpointsTestsData[testSetKey]; + + testUtil.getCallableDescribe(testSet)(testSet.name, () => { + testSet.tests.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, async () => { + const endpointsStateValidator = testUtil.getSpoiledDataValidator(testConf.endpointList); + const totalAttempts = testConf.totalAttempts || DEFAULT_TOTAL_ATTEMPTS; + + const customProps = properties.custom(testConf.endpointList, []); + const customPropsStateValidator = testUtil.getSpoiledDataValidator(customProps); + + const loader = new Loader(localhost, { + logger: logger.getChild('loader'), + workers: 5 + }); + loader.setEndpoints(customProps.endpoints); + + const collector = new Collector(loader, customProps.properties, { + isCustom: true, + logger: logger.getChild('collector'), + workers: 5 + }); + + for (let i = 1; i < totalAttempts + 1; i += 1) { + testUtil.nockCleanup(); + mockEndpoints(testConf.endpoints); + loader.eraseCache(); + + const results = await collector.collect(); + assert.deepStrictEqual(results.stats, testConf.expectedData, `should match expected output (attempt #${i})`); + + endpointsStateValidator(); + customPropsStateValidator(); + } + }); + }); + }); + }); +}); diff --git a/test/unit/data/propertiesJsonTests/collectContext.js b/test/unit/systemPoller/data/contextTestsData.js similarity index 88% rename from test/unit/data/propertiesJsonTests/collectContext.js rename to test/unit/systemPoller/data/contextTestsData.js index 74cc5e8e..4866938b 100644 --- a/test/unit/data/propertiesJsonTests/collectContext.js +++ b/test/unit/systemPoller/data/contextTestsData.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -42,12 +39,11 @@ module.exports = { * */ { name: 'should collect context data', - statsToCollect: () => ({}), - contextToCollect: (context) => context, - getCollectedData: (promise, stats) => promise.then(() => stats.contextData), + defaultContextData: true, endpoints: [ { endpoint: '/mgmt/tm/sys/provision', + replyTimes: 1, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -75,9 +71,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', @@ -89,6 +83,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + replyTimes: 1, response: { kind: 'tm:sys:db:dbstate', name: 'systemauth.disablebash', @@ -128,12 +123,10 @@ module.exports = { * */ { name: 'should not fail when no data (with items property)', - statsToCollect: () => ({}), - contextToCollect: (context) => context, - getCollectedData: (promise, stats) => promise.then(() => stats.contextData), endpoints: [ { endpoint: '/mgmt/tm/sys/provision', + replyTimes: 1, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -142,9 +135,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' @@ -152,6 +143,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + replyTimes: 1, response: { kind: 'tm:sys:db:dbstate', selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2' @@ -171,12 +163,10 @@ module.exports = { * */ { name: 'should not fail when no data (without items property)', - statsToCollect: () => ({}), - contextToCollect: (context) => context, - getCollectedData: (promise, stats) => promise.then(() => stats.contextData), endpoints: [ { endpoint: '/mgmt/tm/sys/provision', + replyTimes: 1, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0' @@ -184,9 +174,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' @@ -194,6 +182,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + replyTimes: 1, response: { kind: 'tm:sys:db:dbstate', selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2' diff --git a/test/unit/data/customEndpointsTestsData.js b/test/unit/systemPoller/data/customEndpointsTestsData.js similarity index 97% rename from test/unit/data/customEndpointsTestsData.js rename to test/unit/systemPoller/data/customEndpointsTestsData.js index 2de5712b..af4450c7 100644 --- a/test/unit/data/customEndpointsTestsData.js +++ b/test/unit/systemPoller/data/customEndpointsTestsData.js @@ -27,6 +27,7 @@ module.exports = { name: 'should set virtualServers to { items: [] } if not configured (with items property as empty array)', endpointList: { virtualServers: { + enable: true, name: 'virtualServers', path: '/mgmt/tm/ltm/virtual' } @@ -54,6 +55,7 @@ module.exports = { name: 'should set virtualServers to { items: [] } if not configured (without items property)', endpointList: { virtualServers: { + enable: true, name: 'virtualServers', path: '/mgmt/tm/ltm/virtual' } @@ -80,10 +82,12 @@ module.exports = { name: 'should collect virtual servers and stats', endpointList: { virtualServers: { + enable: true, name: 'virtualServers', path: '/mgmt/tm/ltm/virtual?$select=name,kind,partition,fullPath,destination' }, virtualServersStats: { + enable: true, name: 'virtualServersStats', path: '/mgmt/tm/ltm/virtual/stats' } @@ -509,6 +513,7 @@ module.exports = { totalAttempts: 2, endpointList: { test: { + enable: true, name: 'test', path: '/does/not/exist' } @@ -536,10 +541,12 @@ module.exports = { totalAttempts: 2, endpointList: { virtualServers: { + enable: true, name: 'virtualServers', path: '/mgmt/tm/ltm/virtual' }, test: { + enable: true, name: 'test', path: '/does/not/exist' } @@ -592,6 +599,8 @@ module.exports = { name: 'should set tmmPages to empty object (if empty response)', endpointList: { tmmPages: { + enable: true, + name: 'tmmPages', path: 'sysTmmPagesStat.sysTmmPagesStatTable.sysTmmPagesStatEntry', protocol: 'snmp' } @@ -618,6 +627,8 @@ module.exports = { name: 'should set tmmPages to empty object (if no commandResult property)', endpointList: { tmmPages: { + enable: true, + name: 'tmmPages', path: 'sysTmmPagesStat.sysTmmPagesStatTable.sysTmmPagesStatEntry', protocol: 'snmp' } @@ -643,6 +654,8 @@ module.exports = { name: 'should collect single snmp mib (single stat)', endpointList: { totalMemory: { + enable: true, + name: 'totalMemory', path: 'sysGlobalStat.sysStatMemoryTotal', protocol: 'snmp' } @@ -671,6 +684,8 @@ module.exports = { name: 'should collect single snmp mib (multiple stats)', endpointList: { tmmPages: { + enable: true, + name: 'tmmPages', path: 'sysTmmPagesStat.sysTmmPagesStatTable.sysTmmPagesStatEntry', protocol: 'snmp' } @@ -706,23 +721,33 @@ module.exports = { name: 'should collect multiple snmp mibs', endpointList: { totalMemory: { + enable: true, + name: 'totalMemory', path: 'sysGlobalStat.sysStatMemoryTotal', protocol: 'snmp' }, usedMemory: { + enable: true, + name: 'usedMemory', path: 'sysGlobalStat.sysStatMemoryUsed', protocol: 'snmp' }, tmmPages: { + enable: true, + name: 'tmmPages', path: 'sysTmmPagesStat.sysTmmPagesStatTable.sysTmmPagesStatEntry', protocol: 'snmp' }, enumToNumeric: { + enable: true, + name: 'enumToNumeric', path: 'ifAdmin.isUp', protocol: 'snmp', numericalEnums: true }, enumAsIs: { + enable: true, + name: 'enumAsIs', path: 'ifAdmin.isUp', protocol: 'snmp' } diff --git a/test/unit/data/propertiesJsonTests/collectClientSslProfiles.js b/test/unit/systemPoller/data/defaultProperties/collectClientSslProfiles.js similarity index 99% rename from test/unit/data/propertiesJsonTests/collectClientSslProfiles.js rename to test/unit/systemPoller/data/defaultProperties/collectClientSslProfiles.js index 7592e983..455459f7 100644 --- a/test/unit/data/propertiesJsonTests/collectClientSslProfiles.js +++ b/test/unit/systemPoller/data/defaultProperties/collectClientSslProfiles.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set client ssl to empty object if not configured', statsToCollect: ['clientSslProfiles'], - contextToCollect: [], expectedData: { clientSslProfiles: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect client ssl profiles stats', statsToCollect: ['clientSslProfiles'], - contextToCollect: [], expectedData: { clientSslProfiles: { '/Common/clientssl': { diff --git a/test/unit/data/propertiesJsonTests/collectDeviceGroups.js b/test/unit/systemPoller/data/defaultProperties/collectDeviceGroups.js similarity index 95% rename from test/unit/data/propertiesJsonTests/collectDeviceGroups.js rename to test/unit/systemPoller/data/defaultProperties/collectDeviceGroups.js index cad61a61..e0bd7447 100644 --- a/test/unit/data/propertiesJsonTests/collectDeviceGroups.js +++ b/test/unit/systemPoller/data/defaultProperties/collectDeviceGroups.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,13 +40,12 @@ module.exports = { { name: 'should set device groups to empty object if not configured (with items property)', statsToCollect: ['deviceGroups'], - contextToCollect: [], expectedData: { deviceGroups: {} }, endpoints: [ { - endpoint: /\/mgmt\/tm\/cm\/device-group/, + endpoint: '/mgmt/tm/cm/device-group', response: { kind: 'tm:cm:device-group:device-groupcollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device-group?ver=14.1.0', @@ -64,13 +60,12 @@ module.exports = { { name: 'should set device groups to empty object if not configured (without items property)', statsToCollect: ['deviceGroups'], - contextToCollect: [], expectedData: { deviceGroups: {} }, endpoints: [ { - endpoint: /\/mgmt\/tm\/cm\/device-group/, + endpoint: '/mgmt/tm/cm/device-group', response: { kind: 'tm:cm:device-group:device-groupcollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device-group?ver=14.1.0' @@ -84,7 +79,6 @@ module.exports = { { name: 'should collect device groups stats', statsToCollect: ['deviceGroups'], - contextToCollect: [], expectedData: { deviceGroups: { '/Common/datasync-device-ts-big-inst.localhost.localdomain-dg': { @@ -99,7 +93,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/cm\/device-group/, + endpoint: '/mgmt/tm/cm/device-group', response: { kind: 'tm:cm:device-group:device-groupcollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device-group?ver=14.1.0', diff --git a/test/unit/data/propertiesJsonTests/collectGtmStats.js b/test/unit/systemPoller/data/defaultProperties/collectGtmStats.js similarity index 95% rename from test/unit/data/propertiesJsonTests/collectGtmStats.js rename to test/unit/systemPoller/data/defaultProperties/collectGtmStats.js index b0779ee2..517c2ee3 100644 --- a/test/unit/data/propertiesJsonTests/collectGtmStats.js +++ b/test/unit/systemPoller/data/defaultProperties/collectGtmStats.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -37,54 +34,6 @@ module.exports = { * */ name: 'GTM stats', tests: [ - /** - * TEST DATA STARTS HERE - * */ - { - name: 'should not have gtm properties when gtm module is not provisioned', - statsToCollect: [ - 'aWideIps', - 'aaaaWideIps', - 'cnameWideIps', - 'mxWideIps', - 'naptrWideIps', - 'srvWideIps', - 'aPools', - 'aaaaPools', - 'cnamePools', - 'mxPools', - 'naptrPools', - 'srvPools' - ], - contextToCollect: ['provisioning'], - expectedData: { }, - endpoints: [ - { - endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, - response: { - kind: 'tm:sys:provision:provisioncollectionstate', - selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', - items: [ - { - name: 'gtm', - level: 'none', - // just to be sure that filterKeys works - cpuRatio: 0 - }, - { - name: 'ltm', - level: 'nominal', - // just to be sure that filterKeys works - cpuRatio: 0 - } - ] - } - } - ] - }, /** * TEST DATA STARTS HERE * */ @@ -104,7 +53,6 @@ module.exports = { 'naptrPools', 'srvPools' ], - contextToCollect: ['provisioning'], expectedData: { aWideIps: {}, aaaaWideIps: {}, @@ -122,9 +70,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -145,12 +91,9 @@ module.exports = { } }, { - endpoint: /\/mgmt\/tm\/gtm\/\w+\/\w+$/, - options: { - times: 12 - }, + endpoint: /\/mgmt\/tm\/gtm\/\w+\/\w+\?.*$/, response: (uri) => { - const match = uri.match(/\/mgmt\/tm\/gtm\/(\w+)\/(\w+)$/); + const match = uri.match(/\/mgmt\/tm\/gtm\/(\w+)\/(\w+)\?.*$/); return { kind: `tm:gtm:${match[1]}:${match[2]}:${match[2]}collectionstate`, selfLink: `https://localhost/mgmt/tm/gtm/${match[1]}/${match[2]}?ver=14.1.0`, @@ -179,7 +122,6 @@ module.exports = { 'naptrPools', 'srvPools' ], - contextToCollect: ['provisioning'], expectedData: { aWideIps: {}, aaaaWideIps: {}, @@ -197,9 +139,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -220,12 +160,9 @@ module.exports = { } }, { - endpoint: /\/mgmt\/tm\/gtm\/\w+\/\w+$/, - options: { - times: 12 - }, + endpoint: /\/mgmt\/tm\/gtm\/\w+\/\w+\?.*$/, response: (uri) => { - const match = uri.match(/\/mgmt\/tm\/gtm\/(\w+)\/(\w+)$/); + const match = uri.match(/\/mgmt\/tm\/gtm\/(\w+)\/(\w+)\?.*$/); return { kind: `tm:gtm:${match[1]}:${match[2]}:${match[2]}collectionstate`, selfLink: `https://localhost/mgmt/tm/gtm/${match[1]}/${match[2]}?ver=14.1.0` @@ -253,7 +190,6 @@ module.exports = { 'naptrPools', 'srvPools' ], - contextToCollect: ['provisioning'], expectedData: { aWideIps: { '/Common/testA.com': { @@ -867,9 +803,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -890,12 +824,9 @@ module.exports = { } }, { - endpoint: /\/mgmt\/tm\/gtm\/wideip\/\w+$/, - options: { - times: 6 - }, + endpoint: /\/mgmt\/tm\/gtm\/wideip\/\w+\?.*$/, response: (uri) => { - const recType = uri.match(/\/mgmt\/tm\/gtm\/wideip\/(\w+)$/)[1].toLowerCase(); + const recType = uri.match(/\/mgmt\/tm\/gtm\/wideip\/(\w+)?.*$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); return { kind: `tm:gtm:wideip:${recType}:srvcollectionstate`, @@ -947,9 +878,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/wideip\/\w+\/~Common~test\w+.com\/stats$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/wideip\/(\w+)\/~Common~test\w+.com\/stats$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); @@ -1018,12 +946,9 @@ module.exports = { } }, { - endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+$/, - options: { - times: 6 - }, + endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\?.*$/, response: (uri) => { - const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)$/)[1].toLowerCase(); + const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\?.*$/)[1].toLowerCase(); const ret = { kind: `tm:gtm:pool:${recType}:${recType}collectionstate`, selfLink: `https://localhost/mgmt/tm/gtm/pool/${recType}?ver=14.1.0`, @@ -1078,9 +1003,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/members\/stats$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/members\/stats$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); @@ -1136,9 +1058,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/members$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/members$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); @@ -1199,9 +1118,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/stats$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/stats$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); @@ -1269,7 +1185,6 @@ module.exports = { 'naptrPools', 'srvPools' ], - contextToCollect: ['provisioning'], expectedData: { aPools: { '/Common/ts_a_pool': { @@ -1507,9 +1422,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -1530,12 +1443,9 @@ module.exports = { } }, { - endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+$/, - options: { - times: 6 - }, + endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\?.*$/, response: (uri) => { - const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)$/)[1].toLowerCase(); + const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\?.*$/)[1].toLowerCase(); const ret = { kind: `tm:gtm:pool:${recType}:${recType}collectionstate`, selfLink: `https://localhost/mgmt/tm/gtm/pool/${recType}?ver=14.1.0`, @@ -1590,9 +1500,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/members\/stats$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/members\/stats$/)[1].toLowerCase(); return { @@ -1603,9 +1510,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/members$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/members$/)[1].toLowerCase(); return { @@ -1616,9 +1520,6 @@ module.exports = { }, { endpoint: /\/mgmt\/tm\/gtm\/pool\/\w+\/~Common~ts_\w+_pool\/stats$/, - options: { - times: 6 - }, response: (uri) => { const recType = uri.match(/\/mgmt\/tm\/gtm\/pool\/(\w+)\/~Common~ts_\w+_pool\/stats$/)[1].toLowerCase(); const recTypeUC = recType.toUpperCase(); diff --git a/test/unit/data/propertiesJsonTests/collectHttpProfiles.js b/test/unit/systemPoller/data/defaultProperties/collectHttpProfiles.js similarity index 98% rename from test/unit/data/propertiesJsonTests/collectHttpProfiles.js rename to test/unit/systemPoller/data/defaultProperties/collectHttpProfiles.js index dcc62c0a..d2a6626b 100644 --- a/test/unit/data/propertiesJsonTests/collectHttpProfiles.js +++ b/test/unit/systemPoller/data/defaultProperties/collectHttpProfiles.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set http profiles to empty object if not configured', statsToCollect: ['httpProfiles'], - contextToCollect: [], expectedData: { httpProfiles: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect http profiles stats', statsToCollect: ['httpProfiles'], - contextToCollect: [], expectedData: { httpProfiles: { '/Common/http': { diff --git a/test/unit/data/propertiesJsonTests/collectIRules.js b/test/unit/systemPoller/data/defaultProperties/collectIRules.js similarity index 98% rename from test/unit/data/propertiesJsonTests/collectIRules.js rename to test/unit/systemPoller/data/defaultProperties/collectIRules.js index f08a02a4..de2ae1bb 100644 --- a/test/unit/data/propertiesJsonTests/collectIRules.js +++ b/test/unit/systemPoller/data/defaultProperties/collectIRules.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set irules stats to empty object if not configured', statsToCollect: ['iRules'], - contextToCollect: [], expectedData: { iRules: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect irules stats', statsToCollect: ['iRules'], - contextToCollect: [], expectedData: { iRules: { '/Common/_sys_APM_ExchangeSupport_OA_BasicAuth': { diff --git a/test/unit/data/propertiesJsonTests/collectLtmPolicies.js b/test/unit/systemPoller/data/defaultProperties/collectLtmPolicies.js similarity index 97% rename from test/unit/data/propertiesJsonTests/collectLtmPolicies.js rename to test/unit/systemPoller/data/defaultProperties/collectLtmPolicies.js index 7e01cfe4..499c06a8 100644 --- a/test/unit/data/propertiesJsonTests/collectLtmPolicies.js +++ b/test/unit/systemPoller/data/defaultProperties/collectLtmPolicies.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set ltm policies to empty object if not configured', statsToCollect: ['ltmPolicies'], - contextToCollect: [], expectedData: { ltmPolicies: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect ltm policies stats', statsToCollect: ['ltmPolicies'], - contextToCollect: [], expectedData: { ltmPolicies: { '/Common/asm_auto_l7_policy__test_vs': { diff --git a/test/unit/data/propertiesJsonTests/collectNetworkTunnels.js b/test/unit/systemPoller/data/defaultProperties/collectNetworkTunnels.js similarity index 97% rename from test/unit/data/propertiesJsonTests/collectNetworkTunnels.js rename to test/unit/systemPoller/data/defaultProperties/collectNetworkTunnels.js index a1bda3d4..cd860187 100644 --- a/test/unit/data/propertiesJsonTests/collectNetworkTunnels.js +++ b/test/unit/systemPoller/data/defaultProperties/collectNetworkTunnels.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set network tunnels to empty object if not configured', statsToCollect: ['networkTunnels'], - contextToCollect: [], expectedData: { networkTunnels: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect network tunnels stats', statsToCollect: ['networkTunnels'], - contextToCollect: [], expectedData: { networkTunnels: { '/Common/http-tunnel': { diff --git a/test/unit/data/propertiesJsonTests/collectPools.js b/test/unit/systemPoller/data/defaultProperties/collectPools.js similarity index 99% rename from test/unit/data/propertiesJsonTests/collectPools.js rename to test/unit/systemPoller/data/defaultProperties/collectPools.js index 60f47747..c6bde674 100644 --- a/test/unit/data/propertiesJsonTests/collectPools.js +++ b/test/unit/systemPoller/data/defaultProperties/collectPools.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set pools to empty object if not configured (with items property)', statsToCollect: ['pools'], - contextToCollect: [], expectedData: { pools: {} }, @@ -64,7 +60,6 @@ module.exports = { { name: 'should set pools to empty object if not configured (without items property)', statsToCollect: ['pools'], - contextToCollect: [], expectedData: { pools: {} }, @@ -84,7 +79,6 @@ module.exports = { { name: 'should collect pools stats', statsToCollect: ['pools'], - contextToCollect: [], expectedData: { pools: { '/Common/test_pool_0': { @@ -182,7 +176,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/pool/, + endpoint: /\/mgmt\/tm\/ltm\/pool\?.*/, response: { kind: 'tm:ltm:pool:poolcollectionstate', items: [ diff --git a/test/unit/data/propertiesJsonTests/collectServerSslProfiles.js b/test/unit/systemPoller/data/defaultProperties/collectServerSslProfiles.js similarity index 99% rename from test/unit/data/propertiesJsonTests/collectServerSslProfiles.js rename to test/unit/systemPoller/data/defaultProperties/collectServerSslProfiles.js index 06697f5b..4afb3650 100644 --- a/test/unit/data/propertiesJsonTests/collectServerSslProfiles.js +++ b/test/unit/systemPoller/data/defaultProperties/collectServerSslProfiles.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set server ssl profiles to empty object if not configured', statsToCollect: ['serverSslProfiles'], - contextToCollect: [], expectedData: { serverSslProfiles: {} }, @@ -63,7 +59,6 @@ module.exports = { { name: 'should collect server ssl profiles stats', statsToCollect: ['serverSslProfiles'], - contextToCollect: [], expectedData: { serverSslProfiles: { '/Common/apm-default-serverssl': { diff --git a/test/unit/data/propertiesJsonTests/collectSslCerts.js b/test/unit/systemPoller/data/defaultProperties/collectSslCerts.js similarity index 96% rename from test/unit/data/propertiesJsonTests/collectSslCerts.js rename to test/unit/systemPoller/data/defaultProperties/collectSslCerts.js index 3eed82e9..4556ca18 100644 --- a/test/unit/data/propertiesJsonTests/collectSslCerts.js +++ b/test/unit/systemPoller/data/defaultProperties/collectSslCerts.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,7 +40,6 @@ module.exports = { { name: 'should set ssl certs to empty object if not configured (with items property)', statsToCollect: ['sslCerts'], - contextToCollect: [], expectedData: { sslCerts: {} }, @@ -64,7 +60,6 @@ module.exports = { { name: 'should set ssl certs to empty object if not configured (without items property)', statsToCollect: ['sslCerts'], - contextToCollect: [], expectedData: { sslCerts: {} }, @@ -84,7 +79,6 @@ module.exports = { { name: 'should collect ssl certs stats', statsToCollect: ['sslCerts'], - contextToCollect: [], expectedData: { sslCerts: { 'ca-bundle.crt': { diff --git a/test/unit/data/propertiesJsonTests/collectSystemStats.js b/test/unit/systemPoller/data/defaultProperties/collectSystemStats.js similarity index 96% rename from test/unit/data/propertiesJsonTests/collectSystemStats.js rename to test/unit/systemPoller/data/defaultProperties/collectSystemStats.js index 8c12e436..0a0bee76 100644 --- a/test/unit/data/propertiesJsonTests/collectSystemStats.js +++ b/test/unit/systemPoller/data/defaultProperties/collectSystemStats.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -37,45 +34,53 @@ module.exports = { * */ name: 'System stats', tests: [ - /** - * TEST DATA STARTS HERE - * */ - { - name: 'should create empty system folder', - statsToCollect: ['system'], - contextToCollect: [], - expectedData: { - system: {} - } - }, /** * TEST DATA STARTS HERE * */ { name: 'should not have properties when conditional block results to false', - statsToCollect: [ - 'system', - 'configReady', - 'licenseReady', - 'provisionReady', - 'asmState', - 'lastAsmChange', - 'apmState', - 'afmState', - 'lastAfmDeploy', - 'ltmConfigTime', - 'gtmConfigTime' - ], - contextToCollect: ['deviceVersion', 'provisioning'], + statsToCollect: { + system: { + asmState: true, + lastAsmChange: true, + apmState: true, + afmState: true, + lastAfmDeploy: true, + ltmConfigTime: true, + gtmConfigTime: true, + provisioning: true + } + }, expectedData: { - system: {} + system: { + provisioning: { + afm: { + level: 'none', + name: 'afm' + }, + apm: { + level: 'none', + name: 'apm' + }, + asm: { + level: 'none', + name: 'asm' + }, + gtm: { + level: 'none', + name: 'gtm' + }, + ltm: { + level: 'none', + name: 'ltm' + } + } + } }, endpoints: [ { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', @@ -86,9 +91,8 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -135,17 +139,16 @@ module.exports = { name: 'should collect system stats', statsToCollect: (stats) => { const ret = { - system: stats.system + system: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'system') { - ret[statKey] = stat; + ret.system[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, expectedData: { system: { hostname: 'test.local', @@ -496,9 +499,8 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -538,9 +540,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', @@ -557,6 +557,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, response: { kind: 'tm:sys:db:dbstate', name: 'systemauth.disablebash', @@ -571,9 +572,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/device', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:device:devicecollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device?ver=14.1.0', @@ -594,6 +593,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/management-ip', + replyTimes: 1, response: { kind: 'tm:sys:management-ip:management-ipcollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/management-ip?ver=14.1.0', @@ -606,9 +606,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/ready', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:sys:ready:readystats', selfLink: 'https://localhost/mgmt/tm/sys/ready?ver=14.1.0', @@ -632,10 +630,11 @@ module.exports = { } }, { - endpoint: '/mgmt/tm/net/interface/stats', + endpoint: '/mgmt/tm/net/interface/stats?%24top=30', response: { kind: 'tm:net:interface:interfacecollectionstats', selfLink: 'https://localhost/mgmt/tm/net/interface/stats?ver=14.1.0', + nextLink: 'https://localhost/mgmt/tm/net/interface/stats?%24top=60&%24skip=30', entries: { 'https://localhost/mgmt/tm/net/interface/1.0/stats': { nestedStats: { @@ -693,7 +692,16 @@ module.exports = { } } } - }, + } + } + } + }, + { + endpoint: '/mgmt/tm/net/interface/stats?%24top=60&%24skip=30', + response: { + kind: 'tm:net:interface:interfacecollectionstats', + selfLink: 'https://localhost/mgmt/tm/net/interface/stats?ver=14.1.0', + entries: { 'https://localhost/mgmt/tm/net/interface/1.10/stats': { nestedStats: { entries: { @@ -756,9 +764,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/sync-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:sync-status:sync-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/sync-status?ver=14.1.0', @@ -786,9 +792,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/failover-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:failover-status:failover-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/failover-status?ver=14.1.0', @@ -810,6 +814,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/clock', + replyTimes: 1, response: { kind: 'tm:sys:clock:clockstats', selfLink: 'https://localhost/mgmt/tm/sys/clock?ver=14.1.0', @@ -828,6 +833,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/host-info', + replyTimes: 1, response: { kind: 'tm:sys:host-info:host-infostats', selfLink: 'https://localhost/mgmt/tm/sys/host-info?ver=14.1.0', @@ -885,9 +891,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/memory', - options: { - times: 3 - }, + replyTimes: 1, response: { kind: 'tm:sys:memory:memorystats', selfLink: 'https://localhost/mgmt/tm/sys/memory?ver=14.1.0', @@ -1006,10 +1010,7 @@ module.exports = { } }, { - endpoint: '/mgmt/tm/asm/policies', - options: { - times: 2 - }, + endpoint: '/mgmt/tm/asm/policies?%24top=30', response: { kind: 'tm:asm:policies:policycollectionstate', selfLink: 'https://localhost/mgmt/tm/asm/policies?ver=14.1.4', @@ -1062,9 +1063,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/util/bash', - options: { - times: 2 - }, method: 'post', request: (body) => body.utilCmdArgs.indexOf('profile_access_misc_stat') !== -1, response: { @@ -1074,9 +1072,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/security/firewall/current-state/stats', - options: { - times: 2 - }, response: { kind: 'tm:security:firewall:current-state:current-statestats', selfLink: 'https://localhost/mgmt/tm/security/firewall/current-state/stats?ver=14.1.0', @@ -1823,8 +1818,11 @@ module.exports = { */ { name: 'should collect cpuInfo on a multi CPU device', - statsToCollect: ['system', 'cpu'], - contextToCollect: (context) => context, + statsToCollect: { + system: { + cpu: true + } + }, expectedData: { system: { cpu: 20 @@ -1833,6 +1831,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/host-info', + replyTimes: 1, response: { kind: 'tm:sys:host-info:host-infostats', selfLink: 'https://localhost/mgmt/tm/sys/host-info?ver=14.1.0', @@ -2034,9 +2033,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', @@ -2048,9 +2045,8 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0' @@ -2058,6 +2054,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, response: { kind: 'tm:sys:db:dbstate', name: 'systemauth.disablebash', @@ -2077,8 +2074,13 @@ module.exports = { */ { name: 'should collect memory-host on a multi host device', - statsToCollect: ['system', 'memory', 'tmmMemory', 'swap'], - contextToCollect: (context) => context, + statsToCollect: { + system: { + memory: true, + tmmMemory: true, + swap: true + } + }, expectedData: { system: { memory: 70, @@ -2089,9 +2091,7 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/memory', - options: { - times: 3 - }, + replyTimes: 1, response: { kind: 'tm:sys:memory:memorystats', selfLink: 'https://localhost/mgmt/tm/sys/memory?ver=14.1.0', @@ -2155,9 +2155,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', @@ -2169,6 +2167,8 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/provision', + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0' @@ -2176,6 +2176,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, response: { kind: 'tm:sys:db:dbstate', selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2' @@ -2190,17 +2191,16 @@ module.exports = { name: 'should not fail when no data (with items property)', statsToCollect: (stats) => { const ret = { - system: stats.system + system: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'system') { - ret[statKey] = stat; + ret.system[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, expectedData: { system: { hostname: 'missing data', @@ -2215,6 +2215,9 @@ module.exports = { baseMac: 'missing data', callBackUrl: 'null', configSyncSucceeded: false, + configReady: 'missing data', + provisionReady: 'missing data', + licenseReady: 'missing data', syncColor: 'missing data', syncMode: 'missing data', syncStatus: 'missing data', @@ -2239,9 +2242,8 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -2250,9 +2252,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' @@ -2260,6 +2260,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, response: { kind: 'tm:sys:db:dbstate', selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2' @@ -2267,9 +2268,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/device', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:device:devicecollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device?ver=14.1.0', @@ -2278,6 +2277,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/management-ip', + replyTimes: 1, response: { kind: 'tm:sys:management-ip:management-ipcollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/management-ip?ver=14.1.0', @@ -2286,16 +2286,14 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/ready', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:sys:ready:readystats', selfLink: 'https://localhost/mgmt/tm/sys/ready?ver=14.1.0' } }, { - endpoint: '/mgmt/tm/net/interface/stats', + endpoint: '/mgmt/tm/net/interface/stats?%24top=30', response: { kind: 'tm:net:interface:interfacecollectionstats', selfLink: 'https://localhost/mgmt/tm/net/interface/stats?ver=14.1.0' @@ -2303,9 +2301,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/sync-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:sync-status:sync-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/sync-status?ver=14.1.0' @@ -2313,9 +2309,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/failover-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:failover-status:failover-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/failover-status?ver=14.1.0' @@ -2323,6 +2317,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/clock', + replyTimes: 1, response: { kind: 'tm:sys:clock:clockstats', selfLink: 'https://localhost/mgmt/tm/sys/clock?ver=14.1.0' @@ -2330,6 +2325,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/host-info', + replyTimes: 1, response: { kind: 'tm:sys:host-info:host-infostats', selfLink: 'https://localhost/mgmt/tm/sys/host-info?ver=14.1.0' @@ -2337,9 +2333,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/memory', - options: { - times: 3 - }, + replyTimes: 1, response: { kind: 'tm:sys:memory:memorystats', selfLink: 'https://localhost/mgmt/tm/sys/memory?ver=14.1.0' @@ -2383,9 +2377,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/util/bash', - options: { - times: 2 - }, method: 'post', request: (body) => body.utilCmdArgs.indexOf('profile_access_misc_stat') !== -1, response: { @@ -2394,9 +2385,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/security/firewall/current-state/stats', - options: { - times: 2 - }, response: { kind: 'tm:security:firewall:current-state:current-statestats', selfLink: 'https://localhost/mgmt/tm/security/firewall/current-state/stats?ver=14.1.0' @@ -2439,17 +2427,16 @@ module.exports = { name: 'should not fail when no data (without items property)', statsToCollect: (stats) => { const ret = { - system: stats.system + system: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'system') { - ret[statKey] = stat; + ret.system[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, expectedData: { system: { hostname: 'missing data', @@ -2462,6 +2449,9 @@ module.exports = { platformId: 'missing data', chassisId: 'missing data', baseMac: 'missing data', + configReady: 'missing data', + licenseReady: 'missing data', + provisionReady: 'missing data', callBackUrl: 'null', configSyncSucceeded: false, syncColor: 'missing data', @@ -2488,9 +2478,8 @@ module.exports = { endpoints: [ { endpoint: '/mgmt/tm/sys/provision', - options: { - times: 999 - }, + replyTimes: 1, + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0' @@ -2498,9 +2487,7 @@ module.exports = { }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' @@ -2508,6 +2495,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, response: { kind: 'tm:sys:db:dbstate', selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2' @@ -2515,9 +2503,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/device', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:device:devicecollectionstate', selfLink: 'https://localhost/mgmt/tm/cm/device?ver=14.1.0' @@ -2525,6 +2511,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/management-ip', + replyTimes: 1, response: { kind: 'tm:sys:management-ip:management-ipcollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/management-ip?ver=14.1.0' @@ -2532,16 +2519,14 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/ready', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:sys:ready:readystats', selfLink: 'https://localhost/mgmt/tm/sys/ready?ver=14.1.0' } }, { - endpoint: '/mgmt/tm/net/interface/stats', + endpoint: '/mgmt/tm/net/interface/stats?%24top=30', response: { kind: 'tm:net:interface:interfacecollectionstats', selfLink: 'https://localhost/mgmt/tm/net/interface/stats?ver=14.1.0' @@ -2549,9 +2534,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/sync-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:sync-status:sync-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/sync-status?ver=14.1.0' @@ -2559,9 +2542,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/cm/failover-status', - options: { - times: 999 - }, + replyTimes: 1, response: { kind: 'tm:cm:failover-status:failover-statusstats', selfLink: 'https://localhost/mgmt/tm/cm/failover-status?ver=14.1.0' @@ -2569,6 +2550,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/clock', + replyTimes: 1, response: { kind: 'tm:sys:clock:clockstats', selfLink: 'https://localhost/mgmt/tm/sys/clock?ver=14.1.0' @@ -2576,6 +2558,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/host-info', + replyTimes: 1, response: { kind: 'tm:sys:host-info:host-infostats', selfLink: 'https://localhost/mgmt/tm/sys/host-info?ver=14.1.0' @@ -2583,9 +2566,7 @@ module.exports = { }, { endpoint: '/mgmt/tm/sys/memory', - options: { - times: 3 - }, + replyTimes: 1, response: { kind: 'tm:sys:memory:memorystats', selfLink: 'https://localhost/mgmt/tm/sys/memory?ver=14.1.0' @@ -2629,9 +2610,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/util/bash', - options: { - times: 2 - }, method: 'post', request: (body) => body.utilCmdArgs.indexOf('profile_access_misc_stat') !== -1, response: { @@ -2640,9 +2618,6 @@ module.exports = { }, { endpoint: '/mgmt/tm/security/firewall/current-state/stats', - options: { - times: 2 - }, response: { kind: 'tm:security:firewall:current-state:current-statestats', selfLink: 'https://localhost/mgmt/tm/security/firewall/current-state/stats?ver=14.1.0' diff --git a/test/unit/data/propertiesJsonTests/collectTmstats.js b/test/unit/systemPoller/data/defaultProperties/collectTmstats.js similarity index 96% rename from test/unit/data/propertiesJsonTests/collectTmstats.js rename to test/unit/systemPoller/data/defaultProperties/collectTmstats.js index 73906113..0cc2d9eb 100644 --- a/test/unit/data/propertiesJsonTests/collectTmstats.js +++ b/test/unit/systemPoller/data/defaultProperties/collectTmstats.js @@ -16,7 +16,7 @@ 'use strict'; -const sourceCode = require('../../shared/sourceCode'); +const sourceCode = require('../../../shared/sourceCode'); const defaultProperties = sourceCode('src/lib/properties.json'); @@ -25,6 +25,7 @@ const TMCTL_CMD_REGEXP = /'tmctl\s+-c\s+(.*)'/; const ADD_DEFAULT_CONTEXT_ENDPOINTS = (testEndpoints) => testEndpoints.concat([ { endpoint: '/mgmt/tm/sys/db/systemauth.disablebash', + useForContext: true, method: 'get', response: { kind: 'tm:sys:db:dbstate', @@ -40,6 +41,7 @@ const ADD_DEFAULT_CONTEXT_ENDPOINTS = (testEndpoints) => testEndpoints.concat([ }, { endpoint: '/mgmt/tm/sys/provision', + useForContext: true, response: { kind: 'tm:sys:provision:provisioncollectionstate', selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', @@ -48,9 +50,7 @@ const ADD_DEFAULT_CONTEXT_ENDPOINTS = (testEndpoints) => testEndpoints.concat([ }, { endpoint: '/mgmt/shared/identified-devices/config/device-info', - options: { - times: 999 - }, + useForContext: true, response: { kind: 'shared:resolver:device-groups:deviceinfostate', selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' @@ -63,9 +63,6 @@ const ADD_DEFAULT_CONTEXT_ENDPOINTS = (testEndpoints) => testEndpoints.concat([ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -79,17 +76,6 @@ module.exports = { * */ name: 'TMStats stats', tests: [ - /** - * TEST DATA STARTS HERE - * */ - { - name: 'should set tmstats to empty folder', - statsToCollect: ['tmstats'], - contextToCollect: [], - expectedData: { - tmstats: {} - } - }, /** * TEST DATA STARTS HERE * */ @@ -97,17 +83,16 @@ module.exports = { name: 'should collect tmstats', statsToCollect: (stats) => { const ret = { - tmstats: stats.tmstats + tmstats: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'tmstats') { - ret[statKey] = stat; + ret.tmstats[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, expectedData: { tmstats: { asmCpuUtilStats: [ @@ -859,9 +844,6 @@ module.exports = { { endpoint: '/mgmt/tm/util/bash', method: 'post', - options: { - times: 47 - }, request: (body) => body && body.utilCmdArgs && body.utilCmdArgs.indexOf('tmctl') !== -1, response: (uri, requestBody) => { // requestBody is string @@ -914,27 +896,21 @@ module.exports = { name: 'should not fail when command returns headers only', statsToCollect: (stats) => { const ret = { - tmstats: stats.tmstats + tmstats: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'tmstats') { - ret[statKey] = stat; + ret.tmstats[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, - expectedData: { - tmstats: { } - }, + expectedData: {}, endpoints: ADD_DEFAULT_CONTEXT_ENDPOINTS([ { endpoint: '/mgmt/tm/util/bash', method: 'post', - options: { - times: 47 - }, request: (body) => body && body.utilCmdArgs && body.utilCmdArgs.indexOf('tmctl') !== -1, response: (uri, requestBody) => { // requestBody is string @@ -975,27 +951,21 @@ module.exports = { name: 'should not fail when table doesn\'t exist', statsToCollect: (stats) => { const ret = { - tmstats: stats.tmstats + tmstats: {} }; Object.keys(stats).forEach((statKey) => { const stat = stats[statKey]; if (stat.structure && stat.structure.parentKey === 'tmstats') { - ret[statKey] = stat; + ret.tmstats[statKey] = true; } }); return ret; }, - contextToCollect: (context) => context, - expectedData: { - tmstats: { } - }, + expectedData: {}, endpoints: ADD_DEFAULT_CONTEXT_ENDPOINTS([ { endpoint: '/mgmt/tm/util/bash', method: 'post', - options: { - times: 47 - }, request: (body) => body && body.utilCmdArgs && body.utilCmdArgs.indexOf('tmctl') !== -1, response: { kind: 'tm:util:bash:runstate', diff --git a/test/unit/data/propertiesJsonTests/collectVirtualServers.js b/test/unit/systemPoller/data/defaultProperties/collectVirtualServers.js similarity index 69% rename from test/unit/data/propertiesJsonTests/collectVirtualServers.js rename to test/unit/systemPoller/data/defaultProperties/collectVirtualServers.js index 497e2a94..1bfb3355 100644 --- a/test/unit/data/propertiesJsonTests/collectVirtualServers.js +++ b/test/unit/systemPoller/data/defaultProperties/collectVirtualServers.js @@ -21,9 +21,6 @@ /** * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL */ -/** - * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed - */ module.exports = { /** @@ -43,13 +40,12 @@ module.exports = { { name: 'should set virtualServers to empty object if not configured (with items property)', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: {} }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', @@ -64,13 +60,12 @@ module.exports = { { name: 'should set virtualServers to empty object if not configured (without items property)', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: {} }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0' @@ -84,7 +79,6 @@ module.exports = { { name: 'should collect virtual servers stats', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: { '/Common/app/test_vs_0': { @@ -139,7 +133,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', @@ -463,7 +457,6 @@ module.exports = { { name: 'should collect virtual servers stats and expand profilesReference', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: { '/Common/test_vs_0': { @@ -511,7 +504,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', @@ -710,7 +703,6 @@ module.exports = { { name: 'should expand profilesReference even if no profiles attached (with items property)', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: { '/Common/test_vs_0': { @@ -741,7 +733,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', @@ -923,7 +915,6 @@ module.exports = { { name: 'should expand profilesReference even if no profiles attached (without items property)', statsToCollect: ['virtualServers'], - contextToCollect: [], expectedData: { virtualServers: { '/Common/test_vs_0': { @@ -954,7 +945,7 @@ module.exports = { }, endpoints: [ { - endpoint: /\/mgmt\/tm\/ltm\/virtual/, + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', response: { kind: 'tm:ltm:virtual:virtualcollectionstate', selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', @@ -1128,6 +1119,488 @@ module.exports = { } } ] + }, + + /** + * TEST DATA STARTS HERE + * */ + { + name: 'should collect virtual servers stats and expand profilesReference and use nextLink (pagination)', + statsToCollect: ['virtualServers'], + expectedData: { + virtualServers: { + '/Common/test_vs_0': { + tenant: 'Common', + 'status.statusReason': 'The children pool member(s) either don\'t have service checking enabled, or service check results are not available yet', + availabilityState: 'unknown', + 'clientside.bitsIn': 0, + 'clientside.bitsOut': 0, + 'clientside.curConns': 0, + 'clientside.evictedConns': 0, + 'clientside.maxConns': 0, + 'clientside.pktsIn': 0, + 'clientside.pktsOut': 0, + 'clientside.slowKilled': 0, + 'clientside.totConns': 0, + destination: '10.11.0.2:80', + enabledState: 'enabled', + isAvailable: true, + isEnabled: true, + ipProtocol: 'tcp', + mask: '255.255.255.255', + name: '/Common/test_vs_0', + pool: '/Common/test_pool_0', + profiles: { + '/Common/f5-tcp-lan': { + name: '/Common/f5-tcp-lan', + tenant: 'Common' + }, + '/Common/http': { + name: '/Common/http', + tenant: 'Common' + }, + '/Common/http-proxy-connect': { + name: '/Common/http-proxy-connect', + tenant: 'Common' + }, + '/Common/tcp': { + name: '/Common/tcp', + tenant: 'Common' + } + }, + totRequests: 0 + }, + + '/Common/test_vs_1': { + tenant: 'Common', + 'status.statusReason': 'The children pool member(s) either don\'t have service checking enabled, or service check results are not available yet', + availabilityState: 'unknown', + 'clientside.bitsIn': 0, + 'clientside.bitsOut': 0, + 'clientside.curConns': 0, + 'clientside.evictedConns': 0, + 'clientside.maxConns': 0, + 'clientside.pktsIn': 0, + 'clientside.pktsOut': 0, + 'clientside.slowKilled': 0, + 'clientside.totConns': 0, + destination: '10.11.0.2:80', + enabledState: 'enabled', + isAvailable: true, + isEnabled: true, + ipProtocol: 'tcp', + mask: '255.255.255.255', + name: '/Common/test_vs_1', + pool: '/Common/test_pool_1', + profiles: { + '/Common/f5-tcp-lan-1': { + name: '/Common/f5-tcp-lan-1', + tenant: 'Common' + }, + '/Common/http-1': { + name: '/Common/http-1', + tenant: 'Common' + }, + '/Common/http-proxy-connect-1': { + name: '/Common/http-proxy-connect-1', + tenant: 'Common' + }, + '/Common/tcp-1': { + name: '/Common/tcp-1', + tenant: 'Common' + } + }, + totRequests: 0 + } + } + }, + endpoints: [ + { + endpoint: '/mgmt/tm/ltm/virtual?%24top=30', + response: { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', + nextLink: 'https://localhost/mgmt/tm/ltm/virtual?%24skip=30&%24top60', + items: [ + { + name: 'test_vs_0', + fullPath: '/Common/test_vs_0', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0?ver=14.1.0', + ipProtocol: 'tcp', + mask: '255.255.255.255', + pool: '/Common/test_pool_0', + poolReference: { + link: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_0?ver=14.1.0' + }, + profilesReference: { + link: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0/profiles?ver=14.1.0', + isSubcollection: true + } + } + ] + } + }, + { + endpoint: '/mgmt/tm/ltm/virtual?%24skip=30&%24top60', + response: { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?ver=14.1.0', + items: [ + { + name: 'test_vs_0', + fullPath: '/Common/test_vs_1', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1?ver=14.1.0', + ipProtocol: 'tcp', + mask: '255.255.255.255', + pool: '/Common/test_pool_1', + poolReference: { + link: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_1?ver=14.1.0' + }, + profilesReference: { + link: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1/profiles?ver=14.1.0', + isSubcollection: true + } + } + ] + } + }, + { + endpoint: '/mgmt/tm/ltm/virtual/~Common~test_vs_0/stats', + response: { + kind: 'tm:ltm:virtual:virtualstats', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0/stats?ver=14.1.0', + entries: { + 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0/stats': { + nestedStats: { + kind: 'tm:ltm:virtual:virtualstats', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0/stats?ver=14.1.0', + entries: { + 'clientside.bitsIn': { + value: 0 + }, + 'clientside.bitsOut': { + value: 0 + }, + 'clientside.curConns': { + value: 0 + }, + 'clientside.evictedConns': { + value: 0 + }, + 'clientside.maxConns': { + value: 0 + }, + 'clientside.pktsIn': { + value: 0 + }, + 'clientside.pktsOut': { + value: 0 + }, + 'clientside.slowKilled': { + value: 0 + }, + 'clientside.totConns': { + value: 0 + }, + cmpEnableMode: { + description: 'all-cpus' + }, + cmpEnabled: { + description: 'enabled' + }, + csMaxConnDur: { + value: 0 + }, + csMeanConnDur: { + value: 0 + }, + csMinConnDur: { + value: 0 + }, + destination: { + description: '10.11.0.2:80' + }, + 'ephemeral.bitsIn': { + value: 0 + }, + 'ephemeral.bitsOut': { + value: 0 + }, + 'ephemeral.curConns': { + value: 0 + }, + 'ephemeral.evictedConns': { + value: 0 + }, + 'ephemeral.maxConns': { + value: 0 + }, + 'ephemeral.pktsIn': { + value: 0 + }, + 'ephemeral.pktsOut': { + value: 0 + }, + 'ephemeral.slowKilled': { + value: 0 + }, + 'ephemeral.totConns': { + value: 0 + }, + fiveMinAvgUsageRatio: { + value: 0 + }, + fiveSecAvgUsageRatio: { + value: 0 + }, + tmName: { + description: '/Common/test_vs_0' + }, + oneMinAvgUsageRatio: { + value: 0 + }, + 'status.availabilityState': { + description: 'unknown' + }, + 'status.enabledState': { + description: 'enabled' + }, + 'status.statusReason': { + description: 'The children pool member(s) either don\'t have service checking enabled, or service check results are not available yet' + }, + syncookieStatus: { + description: 'not-activated' + }, + 'syncookie.accepts': { + value: 0 + }, + 'syncookie.hwAccepts': { + value: 0 + }, + 'syncookie.hwSyncookies': { + value: 0 + }, + 'syncookie.hwsyncookieInstance': { + value: 0 + }, + 'syncookie.rejects': { + value: 0 + }, + 'syncookie.swsyncookieInstance': { + value: 0 + }, + 'syncookie.syncacheCurr': { + value: 0 + }, + 'syncookie.syncacheOver': { + value: 0 + }, + 'syncookie.syncookies': { + value: 0 + }, + totRequests: { + value: 0 + } + } + } + } + } + } + }, + { + endpoint: '/mgmt/tm/ltm/virtual/~Common~test_vs_1/stats', + response: { + kind: 'tm:ltm:virtual:virtualstats', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1/stats?ver=14.1.0', + entries: { + 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1/stats': { + nestedStats: { + kind: 'tm:ltm:virtual:virtualstats', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1/stats?ver=14.1.0', + entries: { + 'clientside.bitsIn': { + value: 0 + }, + 'clientside.bitsOut': { + value: 0 + }, + 'clientside.curConns': { + value: 0 + }, + 'clientside.evictedConns': { + value: 0 + }, + 'clientside.maxConns': { + value: 0 + }, + 'clientside.pktsIn': { + value: 0 + }, + 'clientside.pktsOut': { + value: 0 + }, + 'clientside.slowKilled': { + value: 0 + }, + 'clientside.totConns': { + value: 0 + }, + cmpEnableMode: { + description: 'all-cpus' + }, + cmpEnabled: { + description: 'enabled' + }, + csMaxConnDur: { + value: 0 + }, + csMeanConnDur: { + value: 0 + }, + csMinConnDur: { + value: 0 + }, + destination: { + description: '10.11.0.2:80' + }, + 'ephemeral.bitsIn': { + value: 0 + }, + 'ephemeral.bitsOut': { + value: 0 + }, + 'ephemeral.curConns': { + value: 0 + }, + 'ephemeral.evictedConns': { + value: 0 + }, + 'ephemeral.maxConns': { + value: 0 + }, + 'ephemeral.pktsIn': { + value: 0 + }, + 'ephemeral.pktsOut': { + value: 0 + }, + 'ephemeral.slowKilled': { + value: 0 + }, + 'ephemeral.totConns': { + value: 0 + }, + fiveMinAvgUsageRatio: { + value: 0 + }, + fiveSecAvgUsageRatio: { + value: 0 + }, + tmName: { + description: '/Common/test_vs_1' + }, + oneMinAvgUsageRatio: { + value: 0 + }, + 'status.availabilityState': { + description: 'unknown' + }, + 'status.enabledState': { + description: 'enabled' + }, + 'status.statusReason': { + description: 'The children pool member(s) either don\'t have service checking enabled, or service check results are not available yet' + }, + syncookieStatus: { + description: 'not-activated' + }, + 'syncookie.accepts': { + value: 0 + }, + 'syncookie.hwAccepts': { + value: 0 + }, + 'syncookie.hwSyncookies': { + value: 0 + }, + 'syncookie.hwsyncookieInstance': { + value: 0 + }, + 'syncookie.rejects': { + value: 0 + }, + 'syncookie.swsyncookieInstance': { + value: 0 + }, + 'syncookie.syncacheCurr': { + value: 0 + }, + 'syncookie.syncacheOver': { + value: 0 + }, + 'syncookie.syncookies': { + value: 0 + }, + totRequests: { + value: 0 + } + } + } + } + } + } + }, + { + endpoint: '/mgmt/tm/ltm/virtual/~Common~test_vs_0/profiles?$select=name,fullPath', + response: { + kind: 'tm:ltm:virtual:profiles:profilescollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_0/profiles?$select=name%2CfullPath&ver=14.1.0', + items: [ + { + name: 'f5-tcp-lan', + fullPath: '/Common/f5-tcp-lan' + }, + { + name: 'http', + fullPath: '/Common/http' + }, + { + name: 'http-proxy-connect', + fullPath: '/Common/http-proxy-connect' + }, + { + name: 'tcp', + fullPath: '/Common/tcp' + } + ] + } + }, + { + endpoint: '/mgmt/tm/ltm/virtual/~Common~test_vs_1/profiles?$select=name,fullPath', + response: { + kind: 'tm:ltm:virtual:profiles:profilescollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual/~Common~test_vs_1/profiles?$select=name%2CfullPath&ver=14.1.0', + items: [ + { + name: 'f5-tcp-lan-1', + fullPath: '/Common/f5-tcp-lan-1' + }, + { + name: 'http-1', + fullPath: '/Common/http-1' + }, + { + name: 'http-proxy-connect-1', + fullPath: '/Common/http-proxy-connect-1' + }, + { + name: 'tcp-1', + fullPath: '/Common/tcp-1' + } + ] + } + } + ] } ] }; diff --git a/test/unit/data/systemStatsTestsData.js b/test/unit/systemPoller/data/filterStatsTestsData.js similarity index 94% rename from test/unit/data/systemStatsTestsData.js rename to test/unit/systemPoller/data/filterStatsTestsData.js index b95ac97b..ff655050 100644 --- a/test/unit/data/systemStatsTestsData.js +++ b/test/unit/systemPoller/data/filterStatsTestsData.js @@ -28,7 +28,7 @@ module.exports = { * Following options available: * - only (bool) - run this test only (it.only) * */ - _filterStats: [ + filterStats: [ /** * _filterStats options: * - shouldKeep - not strict, just verifies that properties are presented @@ -65,8 +65,8 @@ module.exports = { } } ], - shouldKeep: ['tmstats', 'pools', 'virtualServers'], - shouldRemove: ['system', 'hostname', 'version', 'versionBuild'] + shouldKeep: ['pools', 'virtualServers'], + shouldRemove: ['hostname', 'version', 'versionBuild'] }, // TEST RELATED DATA STARTS HERE { @@ -80,8 +80,8 @@ module.exports = { } } ], - shouldKeep: ['system', 'hostname', 'version'], - shouldRemove: ['tmstats', 'pools', 'virtualServers'] + shouldKeep: ['hostname', 'version'], + shouldRemove: ['pools', 'virtualServers'] }, // TEST RELATED DATA STARTS HERE { @@ -97,7 +97,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname'] + shouldKeepOnly: ['hostname'] }, // TEST RELATED DATA STARTS HERE { @@ -197,8 +197,8 @@ module.exports = { } } ], - shouldKeep: ['system', 'versionBuild'], - shouldRemove: ['tmstats', 'hostname', 'virtualServers', 'iRules', 'pools', 'asmCpuUtilStats', 'serverSslProfiles'] + shouldKeep: ['versionBuild'], + shouldRemove: ['hostname', 'virtualServers', 'iRules', 'pools', 'asmCpuUtilStats', 'serverSslProfiles'] }, // TEST RELATED DATA STARTS HERE { @@ -237,8 +237,8 @@ module.exports = { } } ], - shouldKeep: ['system', 'versionBuild'], - shouldRemove: ['tmstats', 'hostname', 'virtualServers', 'iRules', 'pools', 'asmCpuUtilStats', 'serverSslProfiles'] + shouldKeep: ['versionBuild'], + shouldRemove: ['hostname', 'virtualServers', 'iRules', 'pools', 'asmCpuUtilStats', 'serverSslProfiles'] }, // TEST RELATED DATA STARTS HERE { @@ -254,7 +254,7 @@ module.exports = { } } ], - shouldKeep: ['system', 'tmstats', 'hostname', 'virtualServers', 'iRules', 'pools', 'serverSslProfiles'], + shouldKeep: ['hostname', 'virtualServers', 'iRules', 'pools', 'serverSslProfiles'], shouldRemove: ['asmCpuUtilStats', 'version', 'versionBuild'] }, // TEST RELATED DATA STARTS HERE @@ -271,7 +271,7 @@ module.exports = { } } ], - shouldKeep: ['system', 'tmstats', 'virtualServers', 'iRules', 'pools', 'version', 'versionBuild', 'serverSslProfiles'], + shouldKeep: ['virtualServers', 'iRules', 'pools', 'version', 'versionBuild', 'serverSslProfiles'], shouldRemove: ['asmCpuUtilStats', 'hostname'] }, // TEST RELATED DATA STARTS HERE @@ -353,7 +353,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname'] + shouldKeepOnly: ['hostname'] }, // TEST RELATED DATA STARTS HERE { @@ -385,7 +385,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname', 'virtualServers'] + shouldKeepOnly: ['hostname', 'virtualServers'] }, // TEST RELATED DATA STARTS HERE { @@ -417,7 +417,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname', 'virtualServers'] + shouldKeepOnly: ['hostname', 'virtualServers'] }, // TEST RELATED DATA STARTS HERE { @@ -445,7 +445,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname'] + shouldKeepOnly: ['hostname'] }, // TEST RELATED DATA STARTS HERE { @@ -498,7 +498,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname', 'virtualServers', 'pools', 'diskStorage'] + shouldKeepOnly: ['hostname', 'virtualServers', 'pools', 'diskStorage'] }, // TEST RELATED DATA STARTS HERE { @@ -546,7 +546,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'hostname', 'virtualServers', 'diskStorage', 'pools'] + shouldKeepOnly: ['hostname', 'virtualServers', 'diskStorage', 'pools'] }, // TEST RELATED DATA STARTS HERE { @@ -592,7 +592,7 @@ module.exports = { } } ], - shouldKeepOnly: ['system', 'version', 'pools', 'virtualServers'] + shouldKeepOnly: ['version', 'pools', 'virtualServers'] }, // TEST RELATED DATA STARTS HERE { diff --git a/test/unit/systemPoller/data/loaderTestsData.js b/test/unit/systemPoller/data/loaderTestsData.js new file mode 100644 index 00000000..78485d77 --- /dev/null +++ b/test/unit/systemPoller/data/loaderTestsData.js @@ -0,0 +1,872 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * NOTE: DO NOT REMOVE 'kind' AND 'selfLink' PROPERTIES FROM RESPONSE's TOP LEVEL + */ +/** + * TODO: update/remove 'options: { times: XXXX }' when EndpointLoader's cache will be fixed + */ + +module.exports = [ + { + name: 'should get data empty data', + endpointObj: { + path: '/endpoint' + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X' + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X' + } + } + }, + { + name: 'should fetch just the data', + endpointObj: { + path: '/endpoint' + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value' + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value' + } + ] + } + } + }, + { + name: 'should fetch data with stats when includeStats specified', + endpointObj: { + path: '/endpoint', + includeStats: true + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value' + } + ] + } + }, + { + endpoint: '/endpoint/object1/stats', + response: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + entries: { + 'https://localhost/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + } + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + kind: 'endpoint:stats', + entries: { + 'https://localhost/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + } + } + ] + } + } + }, + { + name: 'should expand reference', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: {} } + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/anotherEndpoint/refObject', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ] + } + } + }, + { + name: 'should expand reference using endpointSuffix', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: { endpointSuffix: '/suffix' } } + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/anotherEndpoint/refObject/suffix', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ] + } + } + }, + { + name: 'should expand reference and include reference\'s stats', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: { includeStats: true } } + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/anotherEndpoint/refObject/stats', + response: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + } + } + }, + { + endpoint: '/anotherEndpoint/refObject', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + }, + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ] + } + } + }, + { + name: 'should expand reference and include reference\'s stats using endpointSuffix', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: { includeStats: true, endpointSuffix: '/suffix' } } + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/anotherEndpoint/refObject/stats', + response: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + } + } + }, + { + endpoint: '/anotherEndpoint/refObject/suffix', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject/suffix?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + }, + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ] + } + } + }, + { + name: 'should include stats, expand reference using suffix and include reference\'s stats', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: { includeStats: true } }, + includeStats: true + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/anotherEndpoint/refObject/stats', + response: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + } + } + }, + { + endpoint: '/anotherEndpoint/refObject', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + }, + { + endpoint: '/endpoint/object1/stats', + response: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + entries: { + 'https://localhost/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + } + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + entries: { + 'https://localhost/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + }, + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/anotherEndpoint/refObject?ver=X.X.X', + entries: { + 'https://localhost/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + }, + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + } + ] + } + } + }, + { + name: 'should not query reference objects without link property', + endpointObj: { + path: '/endpoint', + expandReferences: { someRef: { } } + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + someRefKey: 'value' + } + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + someRefKey: 'value' + } + } + ] + } + } + }, + { + name: 'should not query objects without selfLink property', + endpointObj: { + path: '/endpoint', + includeStats: true + }, + endpoints: [ + { + endpoint: '/endpoint', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + key: 'value', + someRef: { + someRefKey: 'value' + } + } + ] + } + } + ], + expectedData: { + name: '/endpoint', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + key: 'value', + someRef: { + someRefKey: 'value' + } + } + ] + } + } + }, + { + name: 'should follow nextLink', + endpointObj: { + path: '/mgmt/tm/endpoint', + expandReferences: { someRef: { includeStats: true } }, + includeStats: true, + pagination: true + }, + endpoints: [ + { + endpoint: '/mgmt/tm/endpoint?%24top=30', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/mgmt/tm/endpoint?ver=X.X.X', + nextLink: '/mgmt/tm/endpoint?%24top=60&$skip=30', + items: [ + { + name: 'object1', + selfLink: 'https://localhost/mgmt/tm/endpoint/object1?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/mgmt/tm/anotherEndpoint/refObject?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/mgmt/tm/endpoint?%24top=60&$skip=30', + response: { + kind: 'endpoint:state', + selfLink: 'https://localhost/mgmt/tm/endpoint?ver=X.X.X', + items: [ + { + name: 'object2', + selfLink: 'https://localhost/mgmt/tm/endpoint/object2?ver=X.X.X', + key: 'value', + someRef: { + link: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2?ver=X.X.X' + } + } + ] + } + }, + { + endpoint: '/mgmt/tm/anotherEndpoint/refObject/stats', + response: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject/stats?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + } + } + }, + { + endpoint: '/mgmt/tm/anotherEndpoint/refObject', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject?ver=X.X.X', + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + }, + { + endpoint: '/mgmt/tm/endpoint/object1/stats', + response: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object1/stats?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/endpoint/object1/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + } + } + }, + { + endpoint: '/mgmt/tm/anotherEndpoint/refObject2/stats', + response: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2/stats?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/anotherEndpoint/refObject2/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue2' + } + } + } + } + }, + { + endpoint: '/mgmt/tm/anotherEndpoint/refObject2', + response: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2?ver=X.X.X', + someKey: 'someValue2', + items: [ + { + nestedObjectName: 'name2' + } + ] + } + }, + { + endpoint: '/mgmt/tm/endpoint/object2/stats', + response: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object2/stats?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object2/stats?ver=X.X.X', + statKey: 'statValue2' + } + } + } + } + } + ], + expectedData: { + name: '/mgmt/tm/endpoint?%24top=30', + data: { + kind: 'endpoint:state', + selfLink: 'https://localhost/mgmt/tm/endpoint?ver=X.X.X', + items: [ + { + name: 'object1', + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object1?ver=X.X.X', + key: 'value', + entries: { + 'https://localhost/mgmt/tm/endpoint/object1/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object1/stats?ver=X.X.X', + statKey: 'statValue' + } + } + }, + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/anotherEndpoint/refObject/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue' + } + } + }, + someKey: 'someValue', + items: [ + { + nestedObjectName: 'name' + } + ] + } + }, + { + name: 'object2', + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object2?ver=X.X.X', + key: 'value', + entries: { + 'https://localhost/mgmt/tm/endpoint/object2/stats': { + nestedStats: { + kind: 'endpoint:stats', + selfLink: 'https://localhost/mgmt/tm/endpoint/object2/stats?ver=X.X.X', + statKey: 'statValue2' + } + } + }, + someRef: { + kind: 'anotherEndpoint:state', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2?ver=X.X.X', + entries: { + 'https://localhost/mgmt/tm/anotherEndpoint/refObject2/stats': { + nestedStats: { + kind: 'anotherEndpoint:stats', + selfLink: 'https://localhost/mgmt/tm/anotherEndpoint/refObject2/stats?ver=X.X.X', + name: 'anotherStats', + statKey: 'statValue2' + } + } + }, + someKey: 'someValue2', + items: [ + { + nestedObjectName: 'name2' + } + ] + } + } + ] + } + } + } +]; diff --git a/test/unit/systemPoller/data/propertiesTestsData.js b/test/unit/systemPoller/data/propertiesTestsData.js new file mode 100644 index 00000000..a053a350 --- /dev/null +++ b/test/unit/systemPoller/data/propertiesTestsData.js @@ -0,0 +1,446 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable no-useless-escape */ + +module.exports = { + custom: [ + { + name: 'should process various endpoints', + dataActions: [], + endpoints: { + endpoint1: { + enable: true, + name: 'statName', + path: '/something/path' + }, + endpoint2: { + enable: true, + name: 'endpoint2', + path: '/something/path2' + }, + endpoint3: { + enable: true, + name: 'httpStat', + path: '/something/path2', + protocol: 'http' + }, + endpoint4: { + enable: true, + name: 'snmpStat1', + path: 'stat.name', + protocol: 'snmp', + numericalEnums: false + }, + endpoint5: { + enable: true, + name: 'snmpStat2', + path: 'stat2.name', + protocol: 'snmp', + numericalEnums: true + }, + endpoint6: { + enable: true, + name: 'nonBigipStat', + path: '/something/path2/stats' + }, + endpoint7: { + enable: true, + name: 'bigipStat', + path: '/mgmt/tm/something/path2/stats' + }, + endpoint8: { + enable: true, + name: 'endpoint2', + path: '/something/path2' + } + }, + expected: { + endpoints: [ + { + enable: true, + name: 'endpoint1', + path: '/something/path' + }, + { + enable: true, + name: 'endpoint2', + path: '/something/path2' + }, + { + enable: true, + name: 'endpoint3', + path: '/something/path2', + protocol: 'http' + }, + { + enable: true, + name: 'endpoint4', + path: '/mgmt/tm/util/bash', + protocol: 'snmp', + numericalEnums: false, + body: { + command: 'run', + utilCmdArgs: '-c "snmpwalk -L n -O QUs -c public localhost stat.name"' + } + }, + { + enable: true, + name: 'endpoint5', + path: '/mgmt/tm/util/bash', + protocol: 'snmp', + numericalEnums: true, + body: { + command: 'run', + utilCmdArgs: '-c "snmpwalk -L n -O eQUs -c public localhost stat2.name"' + } + }, + { + enable: true, + name: 'endpoint6', + path: '/something/path2/stats' + }, + { + enable: true, + name: 'endpoint7', + path: '/mgmt/tm/something/path2/stats' + }, + { + enable: true, + name: 'endpoint8', + path: '/something/path2' + } + ], + properties: { + statName: { + key: 'endpoint1', + normalization: { + propertyKey: 'statName', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + // overriden!!! + endpoint2: { + key: 'endpoint8', + normalization: { + propertyKey: 'endpoint2', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + httpStat: { + key: 'endpoint3', + normalization: { + propertyKey: 'httpStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + snmpStat1: { + key: 'endpoint4', + normalization: { + propertyKey: 'snmpStat1', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } }, + { + runFunctions: [{ name: 'restructureSNMPEndpoint', args: {} }] + } + ] + } + }, + snmpStat2: { + key: 'endpoint5', + normalization: { + propertyKey: 'snmpStat2', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } }, + { + runFunctions: [{ name: 'restructureSNMPEndpoint', args: {} }] + } + ] + } + }, + nonBigipStat: { + key: 'endpoint6', + normalization: { + propertyKey: 'nonBigipStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + bigipStat: { + key: 'endpoint7', + normalization: { + propertyKey: 'bigipStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } }, + { + renameKeys: { + patterns: { + 'something/path2': { pattern: 'something/path2/(.*)', group: 1 } + } + } + } + ] + } + } + } + } + }, + { + name: 'should process various endpoints and apply filtering based on dataActions', + dataActions: [ + { + excludeData: {}, + enable: true, + locations: { + statName: true, + snmpStat2: true + } + } + ], + endpoints: { + endpoint1: { + enable: true, + name: 'statName', + path: '/something/path' + }, + endpoint2: { + enable: true, + name: 'endpoint2', + path: '/something/path2' + }, + endpoint3: { + enable: true, + name: 'httpStat', + path: '/something/path2', + protocol: 'http' + }, + endpoint4: { + enable: true, + name: 'snmpStat1', + path: 'stat.name', + protocol: 'snmp', + numericalEnums: false + }, + endpoint5: { + enable: true, + name: 'snmpStat2', + path: 'stat2.name', + protocol: 'snmp', + numericalEnums: true + }, + endpoint6: { + enable: true, + name: 'nonBigipStat', + path: '/something/path2/stats' + }, + endpoint7: { + enable: true, + name: 'bigipStat', + path: '/mgmt/tm/something/path2/stats' + }, + endpoint8: { + enable: true, + name: 'endpoint2', + path: '/something/path2' + } + }, + expected: { + endpoints: [ + { + enable: true, + name: 'endpoint1', + path: '/something/path' + }, + { + enable: true, + name: 'endpoint2', + path: '/something/path2' + }, + { + enable: true, + name: 'endpoint3', + path: '/something/path2', + protocol: 'http' + }, + { + enable: true, + name: 'endpoint4', + path: '/mgmt/tm/util/bash', + protocol: 'snmp', + numericalEnums: false, + body: { + command: 'run', + utilCmdArgs: '-c "snmpwalk -L n -O QUs -c public localhost stat.name"' + } + }, + { + enable: true, + name: 'endpoint5', + path: '/mgmt/tm/util/bash', + protocol: 'snmp', + numericalEnums: true, + body: { + command: 'run', + utilCmdArgs: '-c "snmpwalk -L n -O eQUs -c public localhost stat2.name"' + } + }, + { + enable: true, + name: 'endpoint6', + path: '/something/path2/stats' + }, + { + enable: true, + name: 'endpoint7', + path: '/mgmt/tm/something/path2/stats' + }, + { + enable: true, + name: 'endpoint8', + path: '/something/path2' + } + ], + properties: { + // overriden!!! + endpoint2: { + key: 'endpoint8', + normalization: { + propertyKey: 'endpoint2', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + httpStat: { + key: 'endpoint3', + normalization: { + propertyKey: 'httpStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + snmpStat1: { + key: 'endpoint4', + normalization: { + propertyKey: 'snmpStat1', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } }, + { + runFunctions: [{ name: 'restructureSNMPEndpoint', args: {} }] + } + ] + } + }, + nonBigipStat: { + key: 'endpoint6', + normalization: { + propertyKey: 'nonBigipStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } } + ] + } + }, + bigipStat: { + key: 'endpoint7', + normalization: { + propertyKey: 'bigipStat', + normalization: [ + { + renameKeys: { + patterns: { '~': { replaceCharacter: '/', exactMatch: false } } + } + }, + { filterKeys: { exclude: ['kind', 'selfLink'] } }, + { + renameKeys: { + patterns: { + 'something/path2': { pattern: 'something/path2/(.*)', group: 1 } + } + } + } + ] + } + } + } + } + } + ] +}; diff --git a/test/unit/data/systemStatsUtilTestsData.js b/test/unit/systemPoller/data/utilsTestsData.js similarity index 100% rename from test/unit/data/systemStatsUtilTestsData.js rename to test/unit/systemPoller/data/utilsTestsData.js diff --git a/test/unit/systemPoller/defaultEndpointsTests.js b/test/unit/systemPoller/defaultEndpointsTests.js new file mode 100644 index 00000000..4982893b --- /dev/null +++ b/test/unit/systemPoller/defaultEndpointsTests.js @@ -0,0 +1,237 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const contextTestsData = require('./data/contextTestsData'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const Collector = sourceCode('src/lib/systemPoller/collector'); +const defaultPaths = sourceCode('src/lib/paths.json'); +const defaultProperties = sourceCode('src/lib/properties.json'); +const Loader = sourceCode('src/lib/systemPoller/loader'); +const properties = sourceCode('src/lib/systemPoller/properties'); + +const pathsStateValidator = testUtil.getSpoiledDataValidator(defaultPaths); +const propertiesStateValidator = testUtil.getSpoiledDataValidator(defaultProperties); +const testsDataPath = 'test/unit/systemPoller/data/defaultProperties'; + +moduleCache.remember(); + +describe('System Poller / Colletor / Default Endpoints', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + let coreStub; + let logger; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true }); + logger = coreStub.logger.logger; + }); + + afterEach(() => { + testUtil.nockCleanup(); + sinon.restore(); + }); + + const TOTAL_ATTEMPTS = 3; + + const checkResponse = (endpointMock, response) => { + if (typeof response === 'string') { + response = JSON.parse(response); + } + if (!response.kind) { + throw new Error(`Endpoint '${endpointMock.endpoint}' has no property 'kind' in response`); + } + }; + + const mockEndpoints = (endpoints) => { + const bigip = new BigIpApiMock(localhost); + bigip.addPasswordlessUser(defaultUser); + + endpoints.forEach((endpoint) => { + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: endpoint.method, + path: endpoint.endpoint, + replyTimes: Number.isSafeInteger(endpoint.replyTimes) ? endpoint.replyTimes : Infinity, + reqBody: endpoint.request, + response: (uri, body) => { + let responseData = endpoint.response; + if (typeof responseData === 'function') { + responseData = responseData(uri, body); + } + + if (endpoint.skipCheckResponse !== true) { + checkResponse(endpoint, responseData); + } + return [endpoint.code || 200, testUtil.deepCopy(responseData)]; + } + }); + }); + }; + + describe(contextTestsData.name, () => { + contextTestsData.tests.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, async () => { + const contextProprs = properties.context(); + const loader = new Loader(localhost, { + logger: logger.getChild('loader'), + workers: 5 + }); + const totalAttempts = testConf.totalAttempts || TOTAL_ATTEMPTS; + const contextPropsStateValidator = testUtil.getSpoiledDataValidator(contextProprs); + + for (let i = 1; i < totalAttempts + 1; i += 1) { + testUtil.nockCleanup(); + mockEndpoints(testConf.endpoints); + loader.eraseCache(); + + const contextCollector = new Collector(loader, contextProprs.properties, { + logger: logger.getChild('contextCollector'), + workers: 5 + }); + loader.setEndpoints(contextProprs.endpoints); + const results = await contextCollector.collect(); + + assert.deepStrictEqual(results.stats, testConf.expectedData, `should match expected output (attempt #${i}`); + + contextPropsStateValidator(); + pathsStateValidator(); + propertiesStateValidator(); + } + }); + }); + }); + + const loadedTestsData = testUtil.loadModules(testsDataPath); + Object.keys(loadedTestsData).forEach((fileName) => { + const testSet = loadedTestsData[fileName]; + testUtil.getCallableDescribe(testSet)(testSet.name, () => { + testSet.tests.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, async () => { + const contextConf = contextTestsData.tests.find((ctxConf) => ctxConf.defaultContextData); + assert.isDefined(contextConf, 'should be able to find default context test config'); + + let contextEndpoints = {}; + contextConf.endpoints.forEach((endpointConf) => { + contextEndpoints[endpointConf.endpoint] = endpointConf; + }); + + testConf.endpoints + .filter((endpointConf) => endpointConf.useForContext) + .forEach((endpointConf) => { + contextEndpoints[endpointConf.endpoint] = endpointConf; + }); + + contextEndpoints = Object.values(contextEndpoints); + + const loader = new Loader(localhost, { + logger: logger.getChild('loader'), + workers: 5 + }); + const totalAttempts = testConf.totalAttempts || TOTAL_ATTEMPTS; + + const contextProprs = properties.context(); + + loader.setEndpoints(contextProprs.endpoints); + mockEndpoints(contextEndpoints); + + const contextCollector = new Collector(loader, contextProprs.properties, { + logger: logger.getChild('contextCollector'), + workers: 5 + }); + const contextData = (await contextCollector.collect()).stats; + + const locations = {}; + if (testConf.statsToCollect) { + const statsToCollect = typeof testConf.statsToCollect === 'function' + ? testConf.statsToCollect(defaultProperties.stats) + : testConf.statsToCollect; + + if (Array.isArray(statsToCollect)) { + statsToCollect.forEach((s) => { + locations[s] = true; + }); + } else if (typeof statsToCollect === 'object') { + Object.assign(locations, statsToCollect); + } + } + + const dataActions = []; + if (Object.keys(locations).length > 0) { + dataActions.push({ + includeData: {}, + enable: true, + locations + }); + } + + const statsProps = properties.default({ + contextData, + dataActions, + includeTMStats: true, + tags: { + tenant: '`T`', + application: '`A`' + } + }); + + const statsPropsStateValidator = testUtil.getSpoiledDataValidator(statsProps); + + for (let i = 1; i < totalAttempts + 1; i += 1) { + testUtil.nockCleanup(); + loader.eraseCache(); + + mockEndpoints(testConf.endpoints); + + const statsCollector = new Collector(loader, statsProps.properties, { + logger: logger.getChild('statsCollector'), + workers: 5 + }); + loader.setEndpoints(statsProps.endpoints); + const statsData = (await statsCollector.collect()).stats; + + assert.deepStrictEqual(statsData, testConf.expectedData, `should match expected output (attempt #${i}`); + + pathsStateValidator(); + propertiesStateValidator(); + statsPropsStateValidator(); + } + + assert.notIncludeMatch( + coreStub.logger.messages.all, + /"error":\s*"(?:(?!null))/, + 'should not have HTTP error messages' + ); + }); + }); + }); + }); +}); diff --git a/test/unit/systemPoller/helpers.js b/test/unit/systemPoller/helpers.js new file mode 100644 index 00000000..706020fb --- /dev/null +++ b/test/unit/systemPoller/helpers.js @@ -0,0 +1,313 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); + +const localhost = 'localhost'; +let userID = 0; + +function customEndpoints() { + return { + basePath: '/mgmt/tm/ltm', + items: { + virtualServers: { + path: '/virtual' + }, + pools: { + path: '/pool' + } + } + }; +} + +function system(options = {}) { + const ret = dummies.declaration.system.minimal.decrypted(); + [ + 'allowSelfSignedCert', + 'enable', + 'host', + 'port', + 'protocol', + 'trace' + ].forEach((prop) => { + if (typeof options[prop] !== 'undefined') { + ret[prop] = options[prop]; + } + }); + + if (options.username === true || options.passphrase === true) { + userID += 1; + ret.username = `test_user_${userID}`; + } + if (options.passphrase === true) { + ret.passphrase = { + cipherText: `test_passphrase_${userID}` + }; + } + + return ret; +} + +function systemPoller(options = {}) { + const ret = dummies.declaration.systemPoller.minimal.decrypted(); + [ + 'actions', + 'chunkSize', + 'endpointList', + 'httpAgentOpts', + 'interval', + 'tag', + 'workers' + ].forEach((prop) => { + if (typeof options[prop] !== 'undefined') { + ret[prop] = options[prop]; + } + }); + + ret.actions = [{ + includeData: {}, + locations: { + system: { + baseMac: true, + configReady: true, + hostname: true, + licenseReady: true, + machineId: true, + provisionReady: true, + version: true + }, + tmstats: { + cpuInfoStat: true + }, + telemetryEventCategory: true, + telemetryServiceInfo: true + } + }]; + + if (options.endpoints) { + ret.endpointList = customEndpoints(); + ret.actions[0].locations.virtualServers = true; + ret.actions[0].locations.pools = true; + } + return ret; +} + +function checkBigIpRequests(declaration, spies) { + // ignore secrets encryption requests + const secretsURI = '/mgmt/tm/ltm/auth/radius-server'; + const host = declaration.system.host || localhost; + + let allowSelfSignedCert = declaration.system.allowSelfSignedCert; + if (typeof allowSelfSignedCert === 'undefined') { + allowSelfSignedCert = false; + } + + const props = { + agent: Array.isArray(declaration.systemPoller.httpAgentOpts), + strictSSL: !allowSelfSignedCert + }; + + let numbOfRequests = 0; + const numOfChecks = Object.assign({}, props); + Object.keys(numOfChecks).forEach((key) => { + numOfChecks[key] = 0; + }); + + Object.entries(spies).forEach(([key, spy]) => { + if (spy.callCount !== 0) { + spy.args.forEach((args) => { + if (args[0].uri.includes(host) && !args[0].uri.includes(secretsURI)) { + numbOfRequests += 1; + Object.entries(props).forEach(([name, expected]) => { + const actual = args[0][name]; + if (name === 'agent') { + if (expected) { + assert.isDefined(actual); + } else { + assert.isUndefined(actual); + } + } else { + assert.deepStrictEqual(actual, expected, `request.${key} should use ${name} = ${expected}, got ${actual}`); + } + numOfChecks[name] += 1; + }); + } + }); + } + }); + + if (numbOfRequests > 0) { + Object.keys(numOfChecks).forEach((key) => { + assert.isAbove(numOfChecks[key], 0); + }); + } +} + +function attachPoller(pollerConfig, systemConfig) { + if (!systemConfig) { + systemConfig = system(); + } + + systemConfig.systemPoller = 'systemPoller'; + + return { + systemPoller: pollerConfig, + system: systemConfig + }; +} + +function getDeclaration({ + enable = true, + endpoints = false, + interval = undefined, + systemAuthConf = {}, + systemConf = {}, + trace = false +} = {}) { + return attachPoller( + systemPoller( + Object.assign( + { + enable, + endpoints, + interval, + trace + } + ) + ), + system( + Object.assign( + { + enable, + trace + }, + systemConf.value || {}, + systemAuthConf.value || {} + ) + ) + ); +} + +function getStatsReport(custom = false, addTmstats = true) { + if (custom) { + return { + deviceContext: {}, + stats: { + virtualServers: { + items: [ + { + destination: '/Common/172.16.100.17:53', + fullPath: '/Common/default', + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common' + }, + { + destination: '/Common/10.12.12.49:8443', + fullPath: '/Common/vs_with_pool', + kind: 'tm:ltm:virtual:virtualstate', + name: 'vs_with_pool', + partition: 'Common' + } + ] + }, + pools: { + items: [ + { + kind: 'tm:ltm:poll:poolstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + members: 10 + }, + { + kind: 'tm:ltm:pool:poolstate', + name: 'pool_with_members', + partition: 'Common', + fullPath: '/Common/pool_with_members', + members: 12 + } + ] + } + } + }; + } + const ret = { + deviceContext: { + BASE_MAC_ADDR: '00:01:02:0A:0B:D0', + HOSTNAME: 'bigip1', + bashDisabled: !addTmstats, + deviceVersion: '17.1.5', + provisioning: { + afm: { + level: 'none', + name: 'afm' + }, + asm: { + level: 'nominal', + name: 'asm' + }, + ltm: { + level: 'none', + name: 'ltm' + } + } + }, + stats: { + system: { + baseMac: '00:01:02:0A:0B:D0', + configReady: 'yes', + hostname: 'bigip1', + licenseReady: 'yes', + machineId: '00000000-0000-0000-0000-000000000000', + provisionReady: 'yes', + version: '17.1.5', + versionBuild: 'missing data' + } + } + }; + if (addTmstats) { + ret.stats.tmstats = { + cpuInfoStat: [ + { + a: '1', + b: '2', + c: 'spam', + someKey: '/Tenant/app/test' + }, + { + a: '3', + b: '4', + c: 'eggs', + someKey: '/Tenant/test' + } + ] + }; + } + return ret; +} + +module.exports = { + attachPoller, + checkBigIpRequests, + getDeclaration, + getStatsReport, + systemPoller, + system +}; diff --git a/test/unit/systemPoller/loaderTests.js b/test/unit/systemPoller/loaderTests.js new file mode 100644 index 00000000..a230667f --- /dev/null +++ b/test/unit/systemPoller/loaderTests.js @@ -0,0 +1,1098 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-loop-func, no-plusplus, no-restricted-syntax */ +const moduleCache = require('../shared/restoreCache')(); + +const HttpAgent = require('http').Agent; +const HttpsAgent = require('https').Agent; +const querystring = require('querystring'); +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const bigipConnTests = require('../shared/tests/bigipConn'); +const bigipCredsTest = require('../shared/tests/bigipCreds'); +const loaderTestsData = require('./data/loaderTestsData'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const Loader = sourceCode('src/lib/systemPoller/loader'); + +moduleCache.remember(); + +describe('System Poller / Endpoint Loader', () => { + const defaultUser = 'admin'; + const localhost = 'localhost'; + const remotehost = 'remote.hostname.remote.domain'; + let coreStub; + let logger; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true }); + logger = coreStub.logger.logger; + }); + + afterEach(() => { + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('invalid args', () => { + assert.throws(() => new Loader(), 'target host should be a string'); + assert.throws(() => new Loader(''), 'target host should be a non-empty collection'); + assert.throws(() => new Loader(localhost), 'logger should be an instance of Logger'); + assert.throws(() => new Loader(localhost, { + logger, + chunkSize: 0 + }), 'chunkSize should be >= 1, got 0'); + assert.throws(() => new Loader(localhost, { + logger, + chunkSize: Number.MAX_VALUE + }), 'chunkSize should be a safe number'); + assert.throws(() => new Loader(localhost, { + logger, + workers: 0 + }), 'workers should be >= 1, got 0'); + assert.throws(() => new Loader(localhost, { + logger, + workers: Number.MAX_VALUE + }), 'workers should be a safe number'); + + const hosts = [ + localhost, + remotehost + ]; + for (const host of hosts) { + const credsTests = bigipCredsTest(host); + for (const testData of credsTests) { + assert.throws(() => new Loader(host, { + credentials: testData.value, + logger + }), testData.error); + } + + const connTests = bigipConnTests(); + for (const testData of connTests) { + assert.throws(() => new Loader(host, { + connection: testData.value, + credentials: { token: 'token' }, + logger + }), testData.error); + } + } + }); + + it('should set defaults (localhost)', () => { + const eLoader = new Loader(localhost, { logger }); + assert.deepStrictEqual(eLoader.chunkSize, 30); + assert.deepStrictEqual(eLoader.connection, undefined); + assert.deepStrictEqual(eLoader.credentials, {}); + assert.deepStrictEqual(eLoader.host, localhost); + }); + + it('should set defaults (remotehost)', () => { + const eLoader = new Loader(remotehost, { + credentials: { + username: 'test_username_1', + passphrase: 'test_passphrase_1' + }, + logger + }); + assert.deepStrictEqual(eLoader.chunkSize, 30); + assert.deepStrictEqual(eLoader.connection, undefined); + assert.deepStrictEqual(eLoader.credentials, { + username: 'test_username_1', + passphrase: 'test_passphrase_1' + }); + assert.deepStrictEqual(eLoader.host, remotehost); + }); + }); + + const combinations = testUtil.product( + // host config + testUtil.smokeTests.filter([ + { + name: localhost, + value: localhost + }, + { + name: remotehost, + value: remotehost + } + ]), + // credentials config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'admin with passphrase', + value: { username: defaultUser, passphrase: 'test_passphrase_1' } + }, + testUtil.smokeTests.ignore({ + name: 'non-default user', + value: { username: 'test_user_1', passphrase: 'test_passphrase_2' } + }), + testUtil.smokeTests.ignore({ + name: 'non-default passwordless user', + value: { username: 'test_user_1' } + }), + { + name: 'existing token', + value: { token: 'auto' } + } + ]), + // connection config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'non default', + value: { port: 8105, protocol: 'https', allowSelfSignedCert: true } + } + ]) + ); + + combinations.forEach(([hostConf, credentialsConf, connectionConf]) => { + if (hostConf.value === remotehost && !(credentialsConf.value && credentialsConf.value.passphrase)) { + // password-less user does not work with remote host + return; + } + + describe(`host = ${hostConf.name}, user = ${credentialsConf.name}, connection = ${connectionConf.name}`, () => { + let bigip; + let connection; + let credentials; + let host; + let loader; + let requestSpies; + + beforeEach(async () => { + requestSpies = testUtil.requestSpies(); + + connection = testUtil.deepCopy(connectionConf.value); + credentials = testUtil.deepCopy(credentialsConf.value); + host = hostConf.value; + + bigip = new BigIpApiMock(host, { + port: (connection && connection.port) || undefined, + protocol: (connection && connection.protocol) || undefined + }); + + if (credentials && credentials.token) { + bigip.addAuthToken(credentials.token); + } else if (host === remotehost && credentials) { + assert.allOfAssertions( + () => assert.isDefined(credentials.username, 'username should be defined for remote host'), + () => assert.isDefined(credentials.passphrase, 'passphrase should be defined for remote host') + ); + bigip.mockAuth(credentials.username, credentials.passphrase); + } else if (host === localhost) { + bigip.addPasswordlessUser( + (credentials && credentials.username) + ? credentials.username + : defaultUser + ); + } + + loader = new Loader(host, { connection, credentials, logger }); + }); + + afterEach(() => { + let strictSSL = true; + if (connectionConf.value && typeof connectionConf.value.allowSelfSignedCert === 'boolean') { + strictSSL = !connectionConf.value.allowSelfSignedCert; + } + testUtil.checkRequestSpies(requestSpies, { strictSSL }); + }); + + describe('.setEndpoints()', () => { + beforeEach(() => { + testUtil.nockCleanup(); + }); + + it('should set endpoints', () => { + const expected = { + foo: { + name: 'foo', + body: 'bar', + path: 'test' + }, + '/hello/world': { + path: '/hello/world', + body: 'Hello World!' + } + }; + loader.setEndpoints([ + { + name: 'foo', + body: 'bar', + path: 'test' + }, + { + path: '/hello/world', + body: 'Hello World!' + } + ]); + assert.deepStrictEqual(loader._endpoints, expected); + }); + + it('should overwrite endpoints', () => { + const expected = { + bar: { name: 'bar', path: 'test' } + }; + loader._endpoints = { + foo: {} + }; + loader.setEndpoints([ + { + name: 'bar', + path: 'test' + } + ]); + assert.deepStrictEqual(loader._endpoints, expected); + }); + + it('should log message when endpoint exists', () => { + const expected = { + bar: { name: 'bar', path: 'test2' } + }; + loader._endpoints = { + foo: {} + }; + loader.setEndpoints([ + { + name: 'bar', + path: 'test' + }, + { + name: 'bar', + path: 'test2' + } + ]); + assert.deepStrictEqual(loader._endpoints, expected); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Endpoint with key "bar" exists already!/ + ); + }); + }); + + [ + { + name: 'object', + value: { + command: 'run', + utilCmdArgs: '-c \'tmctl $tmctlArgs\'' + } + }, + { + name: 'string', + value: JSON.stringify({ + command: 'run', + utilCmdArgs: '-c \'tmctl $tmctlArgs\'' + }) + } + ].forEach((testConf) => it(`should replace variable in the request body (${testConf.name})`, async () => { + const path = '/endpoint/path'; + + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path, + replyTimes: 2, + response: () => [200, { message: 'OK' }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + ignoreCached: true, + body: testConf.value + }]); + await loader.auth(); + + await assert.becomes(loader.loadEndpoint('myEndpoint', { + replaceStrings: { + '\\$tmctlArgs': 'my-args' + } + }), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + + await assert.becomes(loader.loadEndpoint('myEndpoint', { + replaceStrings: { + '\\$tmctlArgs': 'my-args2' + } + }), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + + assert.deepStrictEqual(JSON.parse(mock.stub.args[0][1]), { + command: 'run', + utilCmdArgs: '-c \'tmctl my-args\'' + }, 'should replace variables'); + + assert.deepStrictEqual(JSON.parse(mock.stub.args[1][1]), { + command: 'run', + utilCmdArgs: '-c \'tmctl my-args2\'' + }, 'should replace variables'); + })); + + [ + { + name: 'default chunk size', + value: undefined + }, + { + name: 'custom chunk size', + value: 50 + } + ].forEach((sizeConf) => { + it(`should use chunk size - ${sizeConf.name}`, async () => { + const path = '/my/endpoint'; + const encodedPath = `${path}?%24top=${sizeConf.value || loader.chunkSize}`; + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: encodedPath, + replyTimes: 1, + response: () => [200, { message: 'OK' }] + }); + + loader = new Loader(host, { + chunkSize: sizeConf.value, + connection, + credentials, + logger + }); + loader.setEndpoints([{ + name: 'myEndpoint', + pagination: true, + path + }]); + + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + + assert.deepStrictEqual( + mock.stub.args[0][0], + encodedPath + ); + }); + + it(`should encode custom query params (chunk size = ${sizeConf.name}`, async () => { + const path = '/my/endpoint'; + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: new RegExp(`${path}?`), + replyTimes: 1, + response: () => [200, { message: 'OK' }] + }); + + loader = new Loader(host, { + chunkSize: sizeConf.value, + connection, + credentials, + logger + }); + loader.setEndpoints([{ + name: 'myEndpoint', + pagination: true, + path, + query: { + $filter: '$filter', + $select: 'name,field$', + $skip: '$skip', + $non_default: 'some$value' + } + }]); + + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + const expected = { + $top: `${sizeConf.value || loader.chunkSize}`, + $filter: '%24filter', + $select: 'name%2Cfield%24', + $skip: '%24skip', + $non_default: 'some%24value' + }; + Object.entries(expected).forEach(([key, value]) => { + delete expected[key]; + expected[querystring.escape(key)] = value; + }); + + assert.deepStrictEqual( + querystring.parse(mock.stub.args[0][0].split('?')[1], '&', '=', { + decodeURIComponent: (s) => s + }), + expected + ); + }); + }); + + it('should error if endpoint is not defined', async () => { + testUtil.nockCleanup(); + await assert.isRejected( + loader.loadEndpoint('badEndpoint'), + /endpointObj should not be undefined/ + ); + }); + + it('should fail when unable to get data', async () => { + loader.setEndpoints([{ name: 'path', path: '/test' }]); + await loader.auth(); + await assert.isRejected( + loader.loadEndpoint('path'), + /Unable to get response from endpoint "path": HTTP Error:/ + ); + }); + + it('should reply with cached response', async () => { + const path = '/endpoint/path'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, { message: 'OK' }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + }); + + it('should reply with cached response (slow request)', async () => { + const path = '/endpoint/path'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: async () => { + await testUtil.sleep(500); + return [200, { message: 'OK' }]; + } + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await Promise.all([ + assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }), + // should use existing request promise + assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }) + ]); + }); + + it('should reply with cached response (slow request error)', async () => { + const path = '/endpoint/path'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 3, // number of retries + response: async () => { + await testUtil.sleep(500); + return [404, 'Not Found']; + } + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await Promise.all([ + assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 404/ + ), + assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 404/ + ) + ]); + }); + + it('should ignore cached response (ignoreCached = true)', async () => { + const path = '/endpoint/path'; + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 2, + response: () => [200, { message: 'OK', num: i++ }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + ignoreCached: true + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 0 } + }); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 1 } + }); + }); + + it('should ignore cached response (request with body)', async () => { + const path = '/endpoint/path'; + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path, + replyTimes: 2, + reqBody: { + test: true + }, + response: () => [200, { message: 'OK', num: i++ }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + body: { + test: true + } + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 0 } + }); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 1 } + }); + }); + + it('should ignore cached response (pagination = true)', async () => { + const path = '/endpoint/path'; + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: `${path}?%24top=${loader.chunkSize}`, + replyTimes: 2, + response: () => [200, { message: 'OK', num: i++ }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + pagination: true + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 0 } + }); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 1 } + }); + }); + + it('should ignore cached response (error response)', async () => { + const path = '/endpoint/path'; + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: `${path}?%24top=${loader.chunkSize}`, + replyTimes: 4, + response: () => { + i += 1; + if (i <= 3) { + return [404, 'Not Found']; + } + return [200, { message: 'OK', num: i }]; + } + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + pagination: true + }]); + await loader.auth(); + await assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 404/ + ); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 4 } + }); + }); + + it('should ignore cached response when query params a different', async () => { + const path = '/endpoint/path'; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, { message: 'OK', num: 1 }] + }); + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: `${path}?test=true`, + replyTimes: 1, + response: () => [200, { message: 'OK', num: 2 }] + }); + + loader.setEndpoints([ + { + name: 'myEndpoint', + path + }, + { + name: 'myEndpoint2', + path, + query: { test: true } + } + ]); + + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 1 } + }); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint2'), { + name: 'myEndpoint2', + data: { message: 'OK', num: 2 } + }); + }); + + it('should ignore cached response when request failed', async () => { + const path = '/endpoint/path'; + + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 6, // 3 * 2 re-tries + response: () => { + i += 1; + if (i > 3) { + return [400, 'Bad request']; + } + return [404, 'Not found']; + } + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 404/ + ); + await assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 400/ + ); + }); + + it('should reload data when cach erased', async () => { + const path = '/endpoint/path'; + let i = 0; + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 2, + response: () => [200, { message: 'OK', num: i++ }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 0 } + }); + + loader.eraseCache(); + // should reply with cached data + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 1 } + }); + }); + + it('should re-try request', async () => { + const path = '/endpoint/path'; + let i = 0; + + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 2, + response: () => [200, { message: 'OK', num: i++ }] + }); + + mock.stub.onFirstCall() + .returns([404, 'Not Found']) + .callThrough(); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK', num: 0 } + }); + }); + + it('should fail when exceeded number of re-try attempts', async () => { + const path = '/endpoint/path'; + let i = 0; + + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 3, + response: () => [200, { message: 'OK', num: i++ }] + }); + + mock.stub.returns([404, 'Not Found']); + + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.isRejected( + loader.loadEndpoint('myEndpoint'), + /Unable to get response from endpoint "myEndpoint": Bad status code: 404/ + ); + }); + + it('should allow conversion of duplicate JSON keys (parseDuplicateKeys = true)', async () => { + const path = '/endpoint/path'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, '{"dupKey": "hello", "dupKey": "from nock", "notADup": "unique"}'] + }); + loader.setEndpoints([{ + name: 'myEndpoint', + path, + parseDuplicateKeys: true + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { + dupKey: ['hello', 'from nock'], + notADup: 'unique' + } + }); + }); + + it('should use JSON.parse by default', async () => { + const path = '/endpoint/path'; + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path, + replyTimes: 1, + response: () => [200, '{"dupKey": "hello", "dupKey": "from nock", "notADup": "unique"}'] + }); + loader.setEndpoints([{ + name: 'myEndpoint', + path + }]); + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { + dupKey: 'from nock', + notADup: 'unique' + } + }); + }); + + it('should use signle worker', async () => { + const path = '/endpoint/path'; + + loader = new Loader(host, { + connection, + credentials, + logger, + workers: 1 + }); + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path, + replyTimes: 2, + response: async () => { + const ret = [200, { message: 'OK', time: Date.now() }]; + await testUtil.sleep(500); + return ret; + } + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + body: 'replace-me', + ignoreCached: true + }]); + + await loader.auth(); + const p = loader.loadEndpoint('myEndpoint', { replaceStrings: { 'replace-me': 'reqID=1' } }) + .then((data) => ({ + data, + time: Date.now() + })); + // should be enough to send the first request + await testUtil.sleep(100); + // the second one should wait in the queue + const res2 = await loader.loadEndpoint('myEndpoint', { replaceStrings: { 'replace-me': 'reqID=2' } }) + .then((data) => ({ + data, + time: Date.now() + })); + const res1 = await p; + + assert.isAbove(res2.data.data.time, res1.data.data.time); + assert.isAbove(res2.time, res1.time); + }); + + it('should use multiple worker', async () => { + const path = '/endpoint/path'; + const response = () => [200, { message: 'OK', time: Date.now() }]; + + loader = new Loader(host, { + connection, + credentials, + logger, + workers: 3 + }); + + const mock = bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path, + replyTimes: 2, + response + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + body: 'replace-me', + ignoreCached: true + }]); + + mock.stub.onFirstCall() + .callsFake(async () => { + const ret = response(); + await testUtil.sleep(900); + return ret; + }) + .onSecondCall() + .callsFake(async () => { + const ret = response(); + await testUtil.sleep(400); + return ret; + }) + .callThrough(); + + await loader.auth(); + const p1 = loader.loadEndpoint('myEndpoint', { replaceStrings: { 'replace-me': 'reqID=1' } }) + .then((data) => ({ + data, + time: Date.now() + })); + // should be enough to send the first request + await testUtil.sleep(100); + + const p2 = loader.loadEndpoint('myEndpoint', { replaceStrings: { 'replace-me': 'reqID=2' } }) + .then((data) => ({ + data, + time: Date.now() + })); + // should be enough to send the second request + await testUtil.sleep(100); + + const res3 = await loader.loadEndpoint('myEndpoint', { replaceStrings: { 'replace-me': 'reqID=3' } }) + .then((data) => ({ + data, + time: Date.now() + })); + + const res1 = await p1; + const res2 = await p2; + + assert.isAbove(res3.data.data.time, res2.data.data.time, 'request #3 should be the last one'); + assert.isAbove(res2.data.data.time, res1.data.data.time, 'request #2 should be the second'); + assert.isAbove(res1.time, res2.time, 'request #1 should be the last one to finish'); + assert.isAbove(res2.time, res3.time, 'request #2 should be the second one to finish'); + }); + + it('should use HTTP agent', async () => { + const path = '/endpoint/path'; + const agent = new ((connection && connection.protocol === 'https') ? HttpsAgent : HttpAgent)(); + + loader = new Loader(host, { + agent, + connection, + credentials, + logger + }); + + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path, + replyTimes: 1, + response: async () => [200, { message: 'OK' }] + }); + + loader.setEndpoints([{ + name: 'myEndpoint', + path, + body: 'replace-me', + ignoreCached: true + }]); + + await loader.auth(); + await assert.becomes(loader.loadEndpoint('myEndpoint'), { + name: 'myEndpoint', + data: { message: 'OK' } + }); + + testUtil.checkRequestSpies(requestSpies, { + agent + }); + }); + + describe('data tests', () => { + const checkResponse = (endpointMock, response) => { + if (!response.kind) { + throw new Error(`Endpoint '${endpointMock.path}' has no property 'kind' in response`); + } + }; + + loaderTestsData.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, async () => { + testConf.endpoints.forEach((endpoint) => { + bigip.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: endpoint.endpoint, + replyTimes: Infinity, + response: () => { + checkResponse(endpoint.endpoint, endpoint.response); + return [200, endpoint.response]; + } + }); + }); + loader.setEndpoints([testConf.endpointObj]); + + await loader.auth(); + await assert.becomes( + loader.loadEndpoint(testConf.endpointObj.path), + testConf.expectedData + ); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/systemPoller/pollerMock.js b/test/unit/systemPoller/pollerMock.js new file mode 100644 index 00000000..24608795 --- /dev/null +++ b/test/unit/systemPoller/pollerMock.js @@ -0,0 +1,425 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const sinon = require('sinon'); + +const BigIpApiMock = require('../shared/bigipAPIMock'); +const sourceCode = require('../shared/sourceCode'); + +const defaultProperties = sourceCode('src/lib/properties.json'); + +const defaultUser = 'admin'; +const localhost = 'localhost'; + +class PollerMock { + /** + * @param {DeviceConfig} device + */ + constructor(device) { + this.httpMockOptions = { replyTimes: 999 }; + + const host = (!device.connection.host || device.connection.host === localhost) + ? localhost + : device.connection.host; + + this.bigip = { + inst: new BigIpApiMock(host, { + port: device.connection.port, + protocol: device.connection.protocol + }) + }; + + if (host === localhost) { + this.bigip.inst.addPasswordlessUser(device.credentials.username || defaultUser); + } else { + this.bigip.auth = this.bigip.inst.mockAuth( + device.credentials.username, + device.credentials.password, + this.httpMockOptions + ); + } + + // default enpoints - device context + this.bashContextStub = this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/tm/sys/db/systemauth.disablebash', + response: () => {}, + ...this.httpMockOptions + }); + + // custom enpoints + this.customPoolsStub = this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/tm/ltm/pool', + response: () => {}, + ...this.httpMockOptions + }); + + // custom enpoints + this.customVirtualsStub = this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/tm/ltm/virtual', + response: () => {}, + ...this.httpMockOptions + }); + + // default enpoints - device stats + this.sysReadyStub = this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/tm/sys/ready', + response: () => {}, + ...this.httpMockOptions + }); + + // default enpoints - device stats + this.tmstatsStub = this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'POST', + path: '/mgmt/tm/util/bash', + reqBody: (body) => body && body.utilCmdArgs && body.utilCmdArgs.indexOf('tmctl') !== -1, + response: () => {}, + ...this.httpMockOptions + }); + + // default enpoints - device context + this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/tm/sys/provision', + response: () => [200, { + kind: 'tm:sys:provision:provisioncollectionstate', + selfLink: 'https://localhost/mgmt/tm/sys/provision?ver=14.1.0', + items: [ + { + name: 'afm', + level: 'none', + // just to be sure that filterKeys works + cpuRatio: 0 + }, + { + name: 'ltm', + level: 'none', + // just to be sure that filterKeys works + cpuRatio: 0 + }, + { + name: 'asm', + level: 'nominal', + // just to be sure that filterKeys works + cpuRatio: 0 + } + ] + }], + ...this.httpMockOptions + }); + + // default enpoints - device context + this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: '/mgmt/shared/identified-devices/config/device-info', + response: () => [200, { + kind: 'shared:resolver:device-groups:deviceinfostate', + selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info', + baseMac: '00:01:2:a:B:d0', + hostname: 'bigip1', + version: '17.1.5', + machineId: '00000000-0000-0000-0000-000000000000' + }], + ...this.httpMockOptions + }); + + // mock all by default + this.getBashContextStub(); + this.getCustomVirtualsStub(); + this.getCustomPoolsStub(); + this.getSysReadyStub(); + this.getTmstatsStub(); + } + + /** Stub for 'is bash enabled' context stub */ + getBashContextStub() { + const data = (enabled) => ({ + kind: 'tm:sys:db:dbstate', + name: 'systemauth.disablebash', + fullPath: 'systemauth.disablebash', + generation: 1, + selfLink: 'https://localhost/mgmt/tm/sys/db/systemauth.disablebash?ver=14.1.2', + defaultValue: 'false', + scfConfig: 'true', + value: String(enabled), + valueRange: 'false true' + }); + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.bashContextStub.stub.callsFake(async () => { + const ret = await customStub(); + if (typeof ret === 'boolean') { + return [200, data(ret)]; + } + return [500, 'bash context endpoint error']; + }); + return customStub; + } + + /** Stub for custom endpoints */ + getCustomPoolsStub() { + const data = { + kind: 'tm:ltm:pool:poolcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/pool', + items: [ + { + kind: 'tm:ltm:poll:poolstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + members: 10 + }, + { + kind: 'tm:ltm:pool:poolstate', + name: 'pool_with_members', + partition: 'Common', + fullPath: '/Common/pool_with_members', + members: 12 + } + ] + }; + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.customPoolsStub.stub.callsFake(async () => (await customStub() + ? [200, data] + : [500, 'custom endpoint error'])); + return customStub; + } + + /** Stub for custom endpoints */ + getCustomVirtualsStub() { + const data = { + kind: 'tm:ltm:virtual:virtualcollectionstate', + selfLink: 'https://localhost/mgmt/tm/ltm/virtual?$select=name%2Ckind%2Cpartition%2CfullPath%2Cdestination&ver=13.1.1', + items: [ + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'default', + partition: 'Common', + fullPath: '/Common/default', + destination: '/Common/172.16.100.17:53' + }, + { + kind: 'tm:ltm:virtual:virtualstate', + name: 'vs_with_pool', + partition: 'Common', + fullPath: '/Common/vs_with_pool', + destination: '/Common/10.12.12.49:8443' + } + ] + }; + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.customVirtualsStub.stub.callsFake(async () => (await customStub() + ? [200, data] + : [500, 'custom endpoint error'])); + return customStub; + } + + /** Stub for system.configReady endpoint */ + getSysReadyStub() { + const data = { + kind: 'tm:sys:ready:readystats', + selfLink: 'https://localhost/mgmt/tm/sys/ready?ver=14.1.0', + entries: { + 'https://localhost/mgmt/tm/sys/ready/0': { + nestedStats: { + entries: { + configReady: { + description: 'yes' + }, + licenseReady: { + description: 'yes' + }, + provisionReady: { + description: 'yes' + } + } + } + } + } + }; + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.sysReadyStub.stub.callsFake(async () => (await customStub() + ? [200, data] + : [500, 'system.configready endpoint error'])); + return customStub; + } + + /** Stub for TMStats endpoints */ + getTmstatsStub() { + const TMCTL_CMD_REGEXP = /'tmctl\s+-c\s+(.*)'/; + const data = (uri, requestBody) => { + // requestBody is string + let tmctlTable = requestBody.match(TMCTL_CMD_REGEXP); + if (!tmctlTable) { + throw new Error(`Unable to find tmctl table in request: ${JSON.stringify(requestBody)}`); + } + tmctlTable = tmctlTable[1]; + let tmctlStat; + + Object.keys(defaultProperties.stats).some((statKey) => { + const stat = defaultProperties.stats[statKey]; + if (stat.structure && stat.structure.parentKey === 'tmstats' && stat.then + && stat.then.keyArgs.replaceStrings['\\$tmctlArgs'].indexOf(tmctlTable) !== -1) { + tmctlStat = stat.then; + return true; + } + return false; + }); + if (!tmctlStat) { + throw new Error(`Unable to find stat for ${tmctlTable}`); + } + const mapKey = tmctlStat.normalization[0].runFunctions[0].args.mapKey; + if (Array.isArray(mapKey)) { + return { + kind: 'tm:util:bash:runstate', + commandResult: [ + ['a', 'b', 'c', mapKey[0], mapKey[1], mapKey[2]], + [1, 2, 'spam', '/Tenant/app/test', '192.168.0.1', 8080], + [3, 4, 'eggs', '/Tenant/test', '192.168.0.1', 8080] + ].join('\n') + }; + } + return { + kind: 'tm:util:bash:runstate', + commandResult: [ + ['a', 'b', 'c', mapKey || 'someKey'], + [1, 2, 'spam', '/Tenant/app/test'], + [3, 4, 'eggs', '/Tenant/test'] + ].join('\n') + }; + }; + const customStub = sinon.stub( + { method() { return true; } }, + 'method' + ); + customStub.returns(true); + + this.tmstatsStub.stub.callsFake(async (uri, requestBody) => (await customStub() + ? [200, data(uri, requestBody)] + : [500, 'tmstats endpoint error'])); + return customStub; + } + + paginaionSetup(pages = 2) { + this.customPoolsStub.remove(); + this.customVirtualsStub.remove(); + + const collectionPath = /\/mgmt\/tm\/ltm\/pool\?(?:%24|\$)top=(\d+)/; + this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: collectionPath, + response: (uri) => { + pages -= 1; + + const csize = parseInt(uri.match(collectionPath)[1], 10); + let cskip = uri.match(/\?.*(?:%24|\$)skip=(\d+)/); + cskip = (cskip ? parseInt(cskip[1], 10) : 0); + + const ret = [200, { + kind: 'tm:ltm:pool:poolcollectionstate', + nextLink: pages ? `https://localhost/mgmt/tm/ltm/pool?%24top=${csize}&%24skip=${cskip + csize}` : '', + items: [] + }]; + + for (let i = cskip; i < (cskip + csize); i += 1) { + ret[1].items.push({ + kind: 'tm:ltm:pool:poolstate', + name: `test_pool_${i}`, + partition: 'Common', + fullPath: `/Common/test_pool_${i}`, + selfLink: `https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_${i}?ver=14.1.0`, + slowRampTime: 10 + }); + } + return ret; + }, + ...this.httpMockOptions + }); + + const statsPath = /\/mgmt\/tm\/ltm\/pool\/~Common~test_pool_(\d+)\/stats/; + this.bigip.inst.mockArbitraryEndpoint({ + authCheck: true, + method: 'GET', + path: statsPath, + response: (uri) => { + const pid = uri.match(statsPath)[1]; + return [200, { + kind: 'tm:ltm:pool:poolstats', + selfLink: `https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_${pid}/stats?ver=14.1.0`, + entries: { + [`https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_${pid}/stats`]: { + nestedStats: { + kind: 'tm:ltm:pool:poolstats', + selfLink: `https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_${pid}/stats?ver=14.1.0`, + entries: { + activeMemberCnt: { + value: parseInt(pid, 10) + } + } + } + } + } + }]; + }, + ...this.httpMockOptions + }); + } +} + +module.exports = PollerMock; + +/** + * @typedef {object} DeviceConfig + * @property {object} connection + * @property {string} [connection.host] + * @property {number} [connection.port] + * @property {string} [connection.protocol] + * @property {object} credentials + * @property {string} [credentials.username] + * @property {string} [credentials.password] + */ diff --git a/test/unit/systemPoller/pollerTests.js b/test/unit/systemPoller/pollerTests.js new file mode 100644 index 00000000..82510da6 --- /dev/null +++ b/test/unit/systemPoller/pollerTests.js @@ -0,0 +1,1349 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-constant-condition, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const helpers = require('./helpers'); +const PollerMock = require('./pollerMock'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); +const SystemPoller = sourceCode('src/lib/systemPoller/poller'); + +moduleCache.remember(); + +/** + * NOTE: + * - there is no way to test HTTP(s) agent because requests never reach real network socket, + * so afterEach verifies that agent was passed to the request module with desired options. + */ + +describe('System Poller / Poller ', () => { + const defaultInteval = 300; + const remotehost = 'remotehost.remotedonmain'; + let appEvents; + let configWorker; + let coreStub; + let declaration; + let fakeClock; + let interval; + let pollerDestroytub; + let pollerStartStub; + let pollerStopStub; + let pollerStruct; + let requestSpies; + let service; + + function createPoller(onePassOnly = false) { + const proxy = makeManagerProxy(); + const poller = new SystemPoller(proxy.proxy, proxy.proxy.reportCallback, { + onePassOnly, + logger: coreStub.logger.logger.getChild('TestPoller') + }); + + return { + poller, + proxy + }; + } + + function createPollerMock(decl) { + return new PollerMock( + { + connection: { + host: decl.system.host, + port: decl.system.port, + protocol: decl.system.protocol + }, + credentials: { + username: decl.system.username, + password: decl.system.passphrase + ? decl.system.passphrase.cipherText + : undefined + } + } + ); + } + + async function forwardClock(time, cb, repeat = 1, delay = 1) { + if (!cb) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + return; + } + + while (true) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + try { + if (await cb()) { + break; + } + } catch (error) { + // igonre + } + } + } + + function getTimeStep() { + let step = 1; + if (declaration) { + step = declaration.systemPoller.interval || defaultInteval; + } + return step * 100; + } + + function makeManagerProxy() { + const proxy = { + config: { + config: null, + decrypted: false + }, + proxy: {}, + reports: [] + }; + Object.defineProperties(proxy.proxy, { + cleanupConfig: { + value: sinon.stub() + }, + getConfig: { + value: sinon.stub() + }, + reportCallback: { + value: sinon.stub() + } + }); + proxy.proxy.cleanupConfig.callsFake(() => { + proxy.config.config = null; + proxy.config.decrypted = false; + }); + proxy.proxy.getConfig.callsFake(async (poller, decrypt = false) => { + if (proxy.config.config === null) { + proxy.config.config = (await (new Promise((resolve) => { + service.emitAsync('config.getConfig', resolve, { + class: 'Telemetry_System_Poller' + }); + })))[0]; + } + if (decrypt && !proxy.config.decrypted) { + proxy.config.config = (await (new Promise((resolve, reject) => { + service.emitAsync('config.decrypt', testUtil.deepCopy(proxy.config.config), (error, decrypted) => { + if (error) { + reject(error); + } else { + resolve(decrypted); + } + }); + }))); + } + return testUtil.deepCopy(proxy.config.config); + }); + proxy.proxy.reportCallback.callsFake((error, poller, report) => { + proxy.reports.push({ error, poller, report }); + }); + + return proxy; + } + + function processDeclaration(decl) { + return configWorker.processDeclaration( + dummies.declaration.base.decrypted(decl) + ); + } + + function verifyReport(report) { + if (report.error) { + // ignore errors, should be verified by the test + return; + } + assert.instanceOf(report.poller, SystemPoller); + + const isCustom = !!declaration.systemPoller.endpointList; + + const metadata = report.report.metadata; + assert.isNumber(metadata.cycleEnd); + assert.isAbove(metadata.cycleEnd, 0); + assert.isNumber(metadata.cycleStart); + assert.isAbove(metadata.cycleStart, 0); + assert.isAbove(metadata.cycleEnd, metadata.cycleStart); + assert.deepStrictEqual(metadata.isCustom, isCustom); + assert.deepStrictEqual(metadata.pollingInterval, pollerStruct.poller.onePassOnly + ? 0 + : (declaration.systemPoller.interval || defaultInteval)); + + const deviceContext = metadata.deviceContext; + const expectedStats = helpers.getStatsReport(isCustom, !deviceContext.bashDisabled); + + assert.deepStrictEqual( + deviceContext, expectedStats.deviceContext + ); + assert.deepStrictEqual( + report.report.stats, expectedStats.stats + ); + } + + function verifyReports() { + pollerStruct.proxy.reports.map(verifyReport); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + declaration = null; + fakeClock = null; + requestSpies = testUtil.requestSpies(); + service = new SafeEventEmitter(); + + pollerDestroytub = sinon.stub(SystemPoller.prototype, 'destroy'); + pollerDestroytub.callThrough(); + pollerStartStub = sinon.stub(SystemPoller.prototype, 'start'); + pollerStartStub.callThrough(); + pollerStopStub = sinon.stub(SystemPoller.prototype, 'stop'); + pollerStopStub.callThrough(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + appEvents.register(service, 'test', [ + 'config.decrypt', + 'config.getConfig' + ]); + + await coreStub.startServices(); + await coreStub.configWorker.configWorker.load(); + + assert.isEmpty(coreStub.logger.messages.error); + coreStub.logger.removeAllMessages(); + }); + + afterEach(async () => { + if (fakeClock) { + fakeClock.stub.restore(); + } + await coreStub.destroyServices(); + + testUtil.nockCleanup(); + sinon.restore(); + + if (declaration) { + helpers.checkBigIpRequests(declaration, requestSpies); + } + if (pollerStruct) { + verifyReports(); + } + }); + + describe('constructor', () => { + it('invalid args', () => { + assert.throws(() => new SystemPoller(undefined, null, {}), /manager should be neither null or undefined/); + assert.throws(() => new SystemPoller({}, null, {}), /callback should be a function/); + assert.throws(() => new SystemPoller({ test: true }, null, {}), /callback should be a function/); + assert.throws(() => new SystemPoller({ test: true }, () => {}, {}), /logger should be an instance of Logger/); + assert.throws(() => new SystemPoller({ test: true }, () => {}, { + logger: coreStub.logger.logger, + onePassOnly: null + }), /onePassOnly should be a boolean/); + }); + }); + + describe('configuration variations', () => { + const combinations = testUtil.product( + // type + [ + { + name: 'interval-based', + value: false + }, + { + name: 'one-pass-only', + value: true + } + ], + // endpoints + [ + { + name: 'default', + value: false + }, + { + name: 'custom', + value: true + } + ], + // system auth + testUtil.smokeTests.filter([ + { + name: 'system without user', + value: undefined + }, + { + name: 'system with user and passphrase', + value: { username: true, passphrase: true } + } + ]), + // system connection + testUtil.smokeTests.filter([ + { + name: 'localhost system', + value: undefined + }, + { + name: 'remote system with non default config', + value: { + host: remotehost, + allowSelfSignedCert: true, + port: 8889, + protocol: 'https' + } + } + ]) + ); + + combinations.forEach(([pollConf, endpointsConf, systemAuthConf, systemConf]) => describe(`type = ${pollConf.name}, endpoints = ${endpointsConf.name}, system = ${systemConf.name}, systemAuth = ${systemAuthConf.name}`, + () => { + if (systemConf.value && systemConf.value.host === remotehost + && !(systemAuthConf.value && systemAuthConf.value.passphrase) + ) { + // password-less users are not supported by remote device + return; + } + + function getDeclaration(enable = true, ival = 60, trace = false) { + return helpers.getDeclaration({ + enable, + endpoints: endpointsConf.value, + interval: ival, + systemAuthConf, + systemConf, + trace + }); + } + + async function applyDeclaration(decl = undefined) { + declaration = decl || getDeclaration(); + await processDeclaration(declaration); + + interval = configWorker.currentConfig.components + .find((c) => c.class === 'Telemetry_System_Poller' + && c.name === 'systemPoller' + && c.systemName === 'system').interval; + + coreStub.logger.removeAllMessages(); + } + + beforeEach(applyDeclaration); + + afterEach(async () => { + if (fakeClock) { + await Promise.all([ + forwardClock(getTimeStep() * 100, () => pollerStruct.poller.isDestroyed()), + pollerStruct.poller.destroy() + ]); + } else { + await pollerStruct.poller.destroy(); + } + }); + + it('should start and finish polling cycle(s)', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => { + if (pollConf.value) { + return proxy.reports.length >= 1; + } + return proxy.reports.length > 2; + }); + + assert.isEmpty(coreStub.logger.messages.error); + + const info = poller.info(); + if (pollConf.value) { + assert.deepStrictEqual(info.state.history.length, 1); + assert.deepStrictEqual(info.state.stats.cycles, 1); + assert.deepStrictEqual(info.state.stats.cyclesCompleted, 1); + assert.deepStrictEqual(info.state.stats.statsCollected, 1); + assert.deepStrictEqual(info.state.stats.statsProcessed, 1); + } else { + assert.isAtLeast(info.state.history.length, proxy.reports.length); + assert.isAtLeast(info.state.stats.cycles, info.state.history.length + 1); + assert.isAbove(info.state.stats.cycles, 2); + assert.deepStrictEqual(info.state.stats.cyclesCompleted, info.state.stats.cycles - 1); + assert.deepStrictEqual(info.state.stats.statsCollected, info.state.stats.cycles - 1); + assert.deepStrictEqual(info.state.stats.statsProcessed, info.state.stats.cycles - 1); + + for (let i = 1; i < info.state.history.length; i += 1) { + const h1 = info.state.history[i - 1]; + const h2 = info.state.history[i]; + + assert.closeTo( + h2.end - h1.schedule, + interval * 1000, + 10 * 1000, + `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be about ${interval}s.` + ); + } + } + }); + + if (systemAuthConf.value) { + it('should report the task is failed when unable to decrypt config (and restart service if needed)', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /System Poller cycle failed due task error[\s\S]*expected decrypt/); + return true; + }); + + if (!pollConf.value) { + coreStub.logger.removeAllMessages(); + } + + await forwardClock(getTimeStep(), () => { + if (pollConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating system poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + return true; + }); + }); + } + + if (systemAuthConf.value && systemConf.value) { + it('should fail task when unable to auth', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.bigip.auth.stub.returns([404, 'Not Found']); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /System Poller cycle failed due task error/); + return true; + }); + + if (!pollConf.value) { + coreStub.logger.removeAllMessages(); + } + + await forwardClock(getTimeStep(), () => { + if (pollConf.value) { + return poller.info().state.history.length === 1; + } + return poller.info().state.history.length >= 2; + }); + + if (pollConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating system poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.match(info.state.history[0].errorMsg, /Error: Bad status code: 404.*authn\/login/); + }); + + it('should log error when callback with error throws error', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.bigip.auth.stub.returns([404, 'Not Found']); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + pollerStruct.proxy.proxy.reportCallback.throws(new Error('report callback error')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /Uncaught error on attempt to call callback[\s\S]*report callback error/); + return true; + }); + + if (!pollConf.value) { + coreStub.logger.removeAllMessages(); + } + + await forwardClock(getTimeStep(), () => { + if (pollConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating system poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + return true; + }); + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.match(info.state.history[0].errorMsg, /Error: Bad status code: 404.*authn\/login/); + }); + } + + it('should log error when callback with report throws error', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + pollerStruct.proxy.proxy.reportCallback.throws(new Error('report callback error')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /System Poller cycle failed due task error[\s\S]*report callback error/); + return true; + }); + + if (!pollConf.value) { + coreStub.logger.removeAllMessages(); + } + + await forwardClock(getTimeStep(), () => { + if (pollConf.value) { + assert.includeMatch(coreStub.logger.messages.debug, /Terminating system poller/); + } else { + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + } + return true; + }); + + const info = poller.info(); + assert.isAtLeast(info.state.stats.cycles, 1); + assert.deepStrictEqual(info.state.history[0].state, 'FAILED'); + assert.match(info.state.history[0].errorMsg, /report callback error/); + }); + + if (pollConf.value) { + it('should return info', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(pollConf.value); + + const { poller } = pollerStruct; + assert.deepStrictEqual(poller.info(), { + nextFireDate: 'not set', + onePassOnly: true, + prevFireDate: 'not set', + state: null, + terminated: false, + timeUntilNextExecution: 'not available' + }); + + await poller.start(); + assert.isTrue(poller.isRunning()); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + const startTime = Date.now(); + await forwardClock(10, () => poller.info().terminated); + + assert.isBelow(Date.now() - startTime, interval * 1000); + + let info = poller.info(); + assert.isTrue(info.onePassOnly); + assert.isTrue(info.terminated); + assert.notDeepEqual(info.nextFireDate, 'not set'); + assert.deepStrictEqual(info.prevFireDate, 'not set'); + assert.isNumber(info.timeUntilNextExecution); + assert.isObject(info.state); + assert.deepStrictEqual(info.state.state.cycleNo, 1); + assert.deepStrictEqual(info.state.state.lastKnownState, 'DONE'); + assert.deepStrictEqual(info.state.state.isCustom, endpointsConf.value); + assert.deepStrictEqual(info.state.state.pollingInterval, 0); + assert.isEmpty(info.state.state.errorMsg); + assert.lengthOf(info.state.history, 1); + assert.deepStrictEqual(info.state.stats, { + cycles: 1, + cyclesCompleted: 1, + statsCollected: 1, + statsProcessed: 1 + }); + + coreStub.logger.removeAllMessages(); + + await forwardClock(getTimeStep(), null, 100); + + info = poller.info(); + assert.isTrue(info.onePassOnly); + assert.isTrue(info.terminated); + assert.isObject(info.state); + assert.lengthOf(info.state.history, 1); + assert.deepStrictEqual(info.state.stats, { + cycles: 1, + cyclesCompleted: 1, + statsCollected: 1, + statsProcessed: 1 + }, 'should not run more than once'); + assert.notIncludeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + assert.lengthOf(pollerStruct.proxy.reports, 1); + assert.isTrue(pollerStruct.proxy.reports.every((r) => !r.error)); + }); + + it('should handle fatal loop error', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + pollerStruct.proxy.proxy.cleanupConfig.throws(new Error('config cleanup error')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /Terminating system poller due uncaught error[\s\S]*config cleanup/); + return true; + }); + }); + } else { + it('should return info', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller } = pollerStruct; + assert.deepStrictEqual(poller.info(), { + nextFireDate: 'not set', + onePassOnly: false, + prevFireDate: 'not set', + state: null, + terminated: false, + timeUntilNextExecution: 'not available' + }); + + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep(), () => pollerStruct.proxy.reports.length > 5); + + const info = poller.info(); + assert.isFalse(info.onePassOnly); + assert.isFalse(info.terminated); + assert.isObject(info.state); + assert.isAbove(info.state.history.length, 5); + assert.isAbove(info.state.stats.cycles, 5); + assert.deepStrictEqual(info.state.stats.cyclesCompleted, info.state.stats.cycles - 1); + assert.deepStrictEqual(info.state.stats.statsCollected, info.state.stats.cycles - 1); + assert.deepStrictEqual(info.state.stats.statsProcessed, info.state.stats.cycles - 1); + assert.includeMatch(coreStub.logger.messages.debug, /Next polling cycle starts on/); + assert.isTrue(pollerStruct.proxy.reports.every((r) => !r.error)); + }); + + it('should keep only 40 records in the history', async () => { + const maxRecords = 40; + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(getTimeStep() * 100, () => proxy.reports.length > maxRecords); + + let info = poller.info(); + assert.isAbove(info.state.stats.cycles, maxRecords); + assert.lengthOf(info.state.history, maxRecords); + + const historyCopy = testUtil.deepCopy(info.state.history); + const rlen = proxy.reports.length; + + await forwardClock(getTimeStep() * 100, () => proxy.reports.length > (rlen + 5)); + + info = poller.info(); + const idx = info.state.history.findIndex((rec) => { + try { + assert.deepStrictEqual(historyCopy[historyCopy.length - 1], rec); + return true; + } catch (error) { + return false; + } + }); + + assert.isNumber(idx); + assert.isAbove(idx, 0); + assert.isBelow(idx, info.state.history.length - 1); + + await Promise.all([ + forwardClock(getTimeStep() * 100, () => poller.isDestroyed()), + poller.destroy() + ]); + fakeClock.stub.restore(); + }); + + it('should be able to schedule long intervals', async () => { + declaration = getDeclaration(); + declaration.systemPoller.interval = 300; + await applyDeclaration(declaration); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + const { poller, proxy } = pollerStruct; + + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(1 * 1000, null, 60); + + assert.deepStrictEqual(proxy.proxy.reportCallback.callCount, 0); + assert.deepStrictEqual(proxy.proxy.cleanupConfig.callCount, 1); + assert.deepStrictEqual(poller.info().state.stats.cycles, 1); + + await forwardClock(1 * 1000, null, 400); + + assert.isAbove(proxy.proxy.reportCallback.callCount, 0); + assert.isAbove(proxy.proxy.cleanupConfig.callCount, 1); + assert.isAbove(poller.info().state.stats.cycles, 1); + }); + + it('should cleanup config on destroy', async () => { + declaration = getDeclaration(); + declaration.systemPoller.interval = 300; + await applyDeclaration(declaration); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + const { poller, proxy } = pollerStruct; + + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(1 * 1000, null, 30); + + assert.deepStrictEqual(proxy.proxy.reportCallback.callCount, 0); + assert.deepStrictEqual(proxy.proxy.cleanupConfig.callCount, 1); + assert.deepStrictEqual(poller.info().state.stats.cycles, 1); + + await Promise.all([ + forwardClock(1, () => pollerStruct.poller.isDestroyed()), + pollerStruct.poller.destroy() + ]); + + assert.deepStrictEqual(proxy.proxy.cleanupConfig.callCount, 2); + }); + + it('should be able to stop poller while it waiting for exec date', async () => { + createPollerMock(declaration); + pollerStruct = createPoller(pollConf.value); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /Next polling cycle starts on/ + ); + return true; + }, true); + + await poller.destroy(); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Sleep routine interrupted: terminated/ + ); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /Starting polling cycle/ + ); + }); + + it('should schedule next cycle with lower delay when current cycle is slow', async () => { + declaration = getDeclaration(); + declaration.systemPoller.interval = 60; + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + (endpointsConf.value + ? pollerMock.getCustomVirtualsStub() + : pollerMock.getSysReadyStub() + ).callsFake(async () => { + await testUtil.sleep(40 * 1000); + return true; + }); + + await forwardClock(500, () => proxy.reports.length >= 2); + + const info = poller.info(); + assert.isAtLeast(info.state.history.length, 2); + + for (let i = 1; i < info.state.history.length; i += 1) { + const h1 = info.state.history[i - 1]; + const h2 = info.state.history[i]; + + assert.closeTo( + h2.schedule - h1.end, + (interval / 2) * 1000, + 20 * 1000, + `delay between dates ${h1.scheduleISO} and ${h2.endISO} should be about ${interval / 2}s.` + ); + assert.closeTo( + h2.schedule - h1.schedule, + interval * 1000, + 5 * 1000, + `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be about ${interval}s.` + ); + } + }); + + if (endpointsConf.value) { + it('should schedule next cycle without delay when current cycle is slow', async () => { + declaration = getDeclaration(); + declaration.systemPoller.interval = 60; + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + (endpointsConf.value + ? pollerMock.getCustomVirtualsStub() + : pollerMock.getSysReadyStub() + ).callsFake(async () => { + await testUtil.sleep(120 * 1000); + return true; + }); + + await forwardClock(500, () => proxy.reports.length >= 2); + + const info = poller.info(); + assert.isAtLeast(info.state.history.length, 2); + + for (let i = 1; i < info.state.history.length; i += 1) { + const h1 = info.state.history[i - 1]; + const h2 = info.state.history[i]; + + assert.closeTo( + h2.start - h1.end, + 5 * 1000, + 5 * 1000, + `delay between dates ${h1.startISO} and ${h2.endISO} should be aboud 10s.` + ); + assert.closeTo( + h2.schedule - h1.schedule, + interval * 1000, + 5 * 1000, + `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be about ${interval}s.` + ); + } + }); + } + } + + it('should use non-default chunk size', async () => { + declaration = getDeclaration(); + declaration.systemPoller.workers = 1; + declaration.systemPoller.chunkSize = 1; + declaration.systemPoller.actions[0].locations = { pools: true }; + if (endpointsConf.value) { + declaration.systemPoller.endpointList.items.pools.path = '/pool?%24top=1'; + } + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.paginaionSetup(); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + await forwardClock(500, () => proxy.reports.length >= 1); + + const report = proxy.reports[0]; + proxy.reports = []; + assert.isNull(report.error); + + if (endpointsConf.value) { + assert.deepStrictEqual(report.report.stats, { + pools: { + items: [ + { + kind: 'tm:ltm:pool:poolstate', + name: 'test_pool_0', + partition: 'Common', + fullPath: '/Common/test_pool_0', + selfLink: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_0?ver=14.1.0', + slowRampTime: 10 + }, + { + kind: 'tm:ltm:pool:poolstate', + name: 'test_pool_1', + partition: 'Common', + fullPath: '/Common/test_pool_1', + selfLink: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_1?ver=14.1.0', + slowRampTime: 10 + } + ] + } + }); + } else { + assert.deepStrictEqual(report.report.stats, { + pools: { + '/Common/test_pool_0': { + activeMemberCnt: 0, + name: '/Common/test_pool_0' + }, + '/Common/test_pool_1': { + activeMemberCnt: 1, + name: '/Common/test_pool_1' + } + } + }); + } + }); + + it('should use HTTP agent options', async () => { + declaration = getDeclaration(); + declaration.systemPoller.httpAgentOpts = [ + { name: 'keepAlive', value: true } + ]; + await applyDeclaration(declaration); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => { + if (pollConf.value) { + return proxy.reports.length >= 1; + } + return proxy.reports.length > 2; + }); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Using HTTP aagent with options.*keepAlive.*true/ + ); + }); + + it('should use multiple workers', async () => { + declaration = getDeclaration(); + declaration.systemPoller.workers = 2; + declaration.splunk = dummies.declaration.consumer.splunk.minimal.decrypted({ format: 'legacy' }); + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(false); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + const requests = []; + const fakeFn = async () => { + const req = { start: Date.now() }; + requests.push(req); + await testUtil.sleep(300); + req.end = Date.now(); + return true; + }; + let keys; + + if (endpointsConf.value) { + pollerMock.getCustomPoolsStub().callsFake(fakeFn); + pollerMock.getCustomVirtualsStub().callsFake(fakeFn); + keys = ['pools', 'virtualServers']; + } else { + pollerMock.getSysReadyStub().callsFake(fakeFn); + pollerMock.getTmstatsStub().callsFake(fakeFn); + keys = ['system', 'tmstats']; + } + + await forwardClock(500, () => proxy.reports.length >= 1); + + const report = proxy.reports[0].report.stats; + assert.hasAllKeys(report, keys); + + assert.isBelow(requests[1].start, requests[0].end, 'should start in parallel'); + }); + + it('should use single worker', async () => { + declaration = getDeclaration(); + declaration.systemPoller.workers = 1; + declaration.splunk = dummies.declaration.consumer.splunk.minimal.decrypted({ format: 'legacy' }); + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(false); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + const requests = []; + const fakeFn = async () => { + const req = { start: Date.now() }; + requests.push(req); + await testUtil.sleep(300); + req.end = Date.now(); + return true; + }; + let keys; + + if (endpointsConf.value) { + pollerMock.getCustomPoolsStub().callsFake(fakeFn); + pollerMock.getCustomVirtualsStub().callsFake(fakeFn); + keys = ['pools', 'virtualServers']; + } else { + pollerMock.getSysReadyStub().callsFake(fakeFn); + pollerMock.getTmstatsStub().callsFake(fakeFn); + keys = ['system', 'tmstats']; + } + + await forwardClock(500, () => proxy.reports.length >= 1); + + const report = proxy.reports[0].report.stats; + assert.hasAllKeys(report, keys); + + assert.isAtLeast(requests[1].start, requests[0].end, 'should start sequentially'); + }); + + if (endpointsConf.value) { + if (!pollConf.value) { + it('should be able to schedule short intervals', async () => { + declaration = getDeclaration(); + declaration.systemPoller.interval = 10; + await applyDeclaration(declaration); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => proxy.reports.length >= 2); + + const info = poller.info(); + assert.isAtLeast(info.state.history.length, 2); + + for (let i = 1; i < info.state.history.length; i += 1) { + const h1 = info.state.history[i - 1]; + const h2 = info.state.history[i]; + + assert.closeTo( + h2.schedule - h1.schedule, + interval * 1000, + 5 * 1000, + `delay between dates ${h1.scheduleISO} and ${h2.scheduleISO} should be about ${interval}s.` + ); + } + }); + } + } else { + it('should fetch TMstats', async () => { + declaration = getDeclaration(); + declaration.splunk = dummies.declaration.consumer.splunk.minimal.decrypted({ format: 'legacy' }); + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(false); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => proxy.reports.length >= 1); + assert.isDefined(proxy.reports[0].report.stats.tmstats); + }); + + it('should skip TMstats when bash disabled', async () => { + declaration = getDeclaration(); + declaration.splunk = dummies.declaration.consumer.splunk.minimal.decrypted({ format: 'legacy' }); + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(true); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => proxy.reports.length >= 1); + assert.isUndefined(proxy.reports[0].report.stats.tmstats); + }); + + it('should fail task when unable to collect context data', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(true); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + pollerMock.getBashContextStub().callsFake(() => 'error'); + + await forwardClock(500, () => proxy.reports.length >= 1); + + const error = proxy.reports[0].error; + assert.instanceOf(error, Error); + assert.match(error, /Poller.collectContext: unable to collect device context data.*Collector.collect unexpected error on attemp to collect stats for "bashDisabled/); + }); + + it('should be able to stop poller during context collection process', async () => { + const pollerMock = createPollerMock(declaration); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + pollerMock.getBashContextStub().callsFake(async () => { + poller.destroy(); + await testUtil.sleep(120 * 1000); + return true; + }); + + await forwardClock(500, () => poller.isDestroyed()); + + assert.lengthOf(proxy.reports, 1); + assert.match(proxy.reports[0].error, /Poller.collectContext: unable to collect device context data: Stats collection routine terminated/); + }); + } + + it('should return empty stats when all stats pre-filtered', async () => { + declaration = getDeclaration(); + declaration.systemPoller.actions[0].locations = { test: true }; + await applyDeclaration(declaration); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await forwardClock(500, () => proxy.reports.length >= 1); + + assert.deepStrictEqual(proxy.reports[0].report.stats, {}); + proxy.reports = []; + }); + + it('should fail task when unable to fetch stats', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(true); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + (endpointsConf.value + ? pollerMock.getCustomVirtualsStub() + : pollerMock.getSysReadyStub() + ).returns(false); + + await forwardClock(500, () => proxy.reports.length >= 1); + + const error = proxy.reports[0].error; + assert.instanceOf(error, Error); + assert.match( + error, + endpointsConf.value + ? /Poller.collectStats: unable to collect stats.*Collector.collect unexpected error on attemp to collect stats for "virtualServers/ + : /Poller.collectStats: unable to collect stats.*Collector.collect unexpected error on attemp to collect stats for.*provisionReady.*Bad status code: 500/ + ); + }); + + it('should be able to stop poller during stats collection process', async () => { + const pollerMock = createPollerMock(declaration); + pollerMock.getBashContextStub().returns(true); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + (endpointsConf.value + ? pollerMock.getCustomVirtualsStub() + : pollerMock.getSysReadyStub() + ).callsFake(async () => { + poller.destroy(); + await testUtil.sleep(120 * 1000); + return true; + }); + + await forwardClock(500, () => poller.isDestroyed()); + + assert.lengthOf(proxy.reports, 1); + assert.match(proxy.reports[0].error, /Poller.collectStats: unable to collect stats: Stats collection routine terminated/); + }); + + it('should handle fatal loop error', async () => { + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + pollerStruct = createPoller(pollConf.value); + pollerStruct.proxy.proxy.cleanupConfig.throws(new Error('config cleanup error')); + + const { poller } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + + await Promise.all([ + poller.destroy(), + forwardClock(getTimeStep(), () => { + assert.includeMatch(coreStub.logger.messages.error, /Terminating system poller due uncaught error[\s\S]*config cleanup/); + return poller.isDestroyed(); + }) + ]); + }); + + it('should use tags', async () => { + declaration = getDeclaration(); + declaration.systemPoller.chunkSize = 1; + declaration.systemPoller.tag = { test: 'test' }; + declaration.systemPoller.workers = 1; + declaration.systemPoller.actions[0].locations = { pools: true }; + if (endpointsConf.value) { + declaration.systemPoller.endpointList.items.pools.path = '/pool?%24top=1'; + } + await applyDeclaration(declaration); + + const pollerMock = createPollerMock(declaration); + pollerMock.paginaionSetup(); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + pollerStruct = createPoller(pollConf.value); + + const { poller, proxy } = pollerStruct; + await poller.start(); + assert.isTrue(poller.isRunning()); + await forwardClock(500, () => proxy.reports.length >= 1); + + const report = proxy.reports[0]; + proxy.reports = []; + assert.isNull(report.error); + + if (endpointsConf.value) { + assert.deepStrictEqual(report.report.stats, { + pools: { + items: [ + { + kind: 'tm:ltm:pool:poolstate', + name: 'test_pool_0', + partition: 'Common', + fullPath: '/Common/test_pool_0', + selfLink: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_0?ver=14.1.0', + slowRampTime: 10 + }, + { + kind: 'tm:ltm:pool:poolstate', + name: 'test_pool_1', + partition: 'Common', + fullPath: '/Common/test_pool_1', + selfLink: 'https://localhost/mgmt/tm/ltm/pool/~Common~test_pool_1?ver=14.1.0', + slowRampTime: 10 + } + ] + } + }); + } else { + assert.deepStrictEqual(report.report.stats, { + pools: { + '/Common/test_pool_0': { + activeMemberCnt: 0, + name: '/Common/test_pool_0', + test: 'test' + }, + '/Common/test_pool_1': { + activeMemberCnt: 1, + name: '/Common/test_pool_1', + test: 'test' + } + } + }); + } + }); + })); + }); +}); diff --git a/test/unit/systemPoller/propertiesTests.js b/test/unit/systemPoller/propertiesTests.js new file mode 100644 index 00000000..d210acd8 --- /dev/null +++ b/test/unit/systemPoller/propertiesTests.js @@ -0,0 +1,136 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const assert = require('../shared/assert'); +const propertiesTestsData = require('./data/propertiesTestsData'); +const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); + +const defaultPaths = sourceCode('src/lib/paths.json'); +const properties = sourceCode('src/lib/systemPoller/properties'); + +moduleCache.remember(); + +describe('System Poller / Properties', () => { + before(() => { + moduleCache.restore(); + }); + + describe('context properties', () => { + it('should build context properties', async () => { + const context = properties.context(); + assert.deepStrictEqual(context.endpoints, defaultPaths.endpoints); + }); + }); + + describe('custom properties', () => { + it('should fail when empty endpoints passed to the function', async () => { + assert.throws(() => properties.custom(), 'endpoints should be an object'); + assert.throws(() => properties.custom({}), 'dataActions should be an array'); + }); + + it('should be able to process empty endpoints list', async () => { + assert.deepStrictEqual( + properties.custom({}, []), + { + endpoints: [], + properties: {} + } + ); + }); + + propertiesTestsData.custom.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, () => assert.deepStrictEqual( + properties.custom(testConf.endpoints, testConf.dataActions), + testConf.expected + )); + }); + }); + + describe('default properties', () => { + it('should build default properties (default values)', async () => { + const props = properties.default({ + contextData: { + bashDisabled: false, + provisioning: {} + }, + dataActions: [] + }); + assert.deepStrictEqual(props.endpoints, defaultPaths.endpoints); + assert.isDefined(props.properties.asmCpuUtilStats); + }); + + it('should ignore TMStats', async () => { + const props = properties.default({ + contextData: { + bashDisabled: true, + provisioning: {} + }, + dataActions: [], + includeTMStats: false + }); + assert.deepStrictEqual(props.endpoints, defaultPaths.endpoints); + assert.isUndefined(props.properties.asmCpuUtilStats); + assert.isUndefined(props.properties.apmState); + }); + + it('should remove disabled properties TMStats', async () => { + const props = properties.default({ + contextData: { + bashDisabled: true, + provisioning: { + asm: { + level: 'nominal' + } + } + }, + dataActions: [], + includeTMStats: true + }); + assert.deepStrictEqual(props.endpoints, defaultPaths.endpoints); + assert.isUndefined(props.properties.asmCpuUtilStats); // bash disabled - removed despite includeTMStats flag + }); + + it('should filter properties based on dataCtions', async () => { + const props = properties.default({ + contextData: { + bashDisabled: true, + provisioning: {} + }, + dataActions: [ + { + excludeData: {}, + enable: true, + locations: { + system: true, + aaaaPools: true + } + } + ], + includeTMStats: false + }); + assert.deepStrictEqual(props.endpoints, defaultPaths.endpoints); + assert.isUndefined(props.properties.asmCpuUtilStats); + assert.isUndefined(props.properties.system); + assert.isUndefined(props.properties.aaaaPools); + }); + }); +}); diff --git a/test/unit/systemPoller/serviceTests.js b/test/unit/systemPoller/serviceTests.js new file mode 100644 index 00000000..5d0a9441 --- /dev/null +++ b/test/unit/systemPoller/serviceTests.js @@ -0,0 +1,1256 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-constant-condition, no-nested-ternary, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const dummies = require('../shared/dummies'); +const helpers = require('./helpers'); +const PollerMock = require('./pollerMock'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); +const testUtil = require('../shared/util'); + +const DataPipeline = sourceCode('src/lib/dataPipeline'); +const ResourceMonitor = sourceCode('src/lib/resourceMonitor'); +const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); +const SystemPollerService = sourceCode('src/lib/systemPoller'); +const SystemPoller = sourceCode('src/lib/systemPoller/poller'); + +moduleCache.remember(); + +describe('System Poller / System Poller Service', () => { + const defaultInteval = 300; + const remotehost = 'remotehost.remotedonmain'; + let appEvents; + let configWorker; + let coreStub; + let dataPipeline; + let declaration; + let fakeClock; + let pollerDestroytub; + let pollerStartStub; + let pollerStopStub; + let reports; + let requestSpies; + let service; + + function createPollerMock(decl) { + return new PollerMock( + { + connection: { + host: decl.system.host, + port: decl.system.port, + protocol: decl.system.protocol + }, + credentials: { + username: decl.system.username, + password: decl.system.passphrase + ? decl.system.passphrase.cipherText + : undefined + } + } + ); + } + + async function forwardClock(time, cb, repeat = 1, delay = 1) { + if (!cb) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + return; + } + + while (true) { + await fakeClock.clockForward(time, { repeat, promisify: true, delay }); + try { + if (await cb()) { + break; + } + } catch (error) { + // igonre + } + } + } + + function getTimeStep() { + let step = 1; + if (declaration) { + step = declaration.systemPoller.interval || defaultInteval; + } + return step * 100; + } + + function processDeclaration(decl, namespace, wait = true, addConsumer = true) { + if (addConsumer) { + decl = Object.assign({}, decl, { + consumer: dummies.declaration.consumer.default.decrypted({}) + }); + } + + let promise; + if (namespace) { + promise = configWorker.processNamespaceDeclaration( + dummies.declaration.namespace.base.decrypted(decl), + namespace + ); + } else { + promise = configWorker.processDeclaration( + dummies.declaration.base.decrypted(decl) + ); + } + return Promise.all([ + wait ? appEvents.waitFor('systemPoller.config.applied') : Promise.resolve(), + promise + ]); + } + + function verifyReport(report, options = {}) { + const isCustom = !!declaration.systemPoller.endpointList; + + const metadata = report.data.telemetryServiceInfo; + assert.isString(metadata.cycleEnd); + assert.isString(metadata.cycleStart); + assert.isNotEmpty(metadata.cycleEnd); + assert.isNotEmpty(metadata.cycleStart); + assert.deepStrictEqual( + metadata.pollingInterval, + typeof options !== 'undefined' && typeof options.interval !== 'undefined' + ? options.interval + : (typeof declaration.systemPoller.interval === 'undefined' + ? defaultInteval + : declaration.systemPoller.interval) + ); + assert.deepStrictEqual(report.isCustom, isCustom); + + const expectedStats = helpers.getStatsReport( + isCustom, + !!(declaration.splunk && declaration.splunk.format === 'legacy') + ); + + Object.entries(expectedStats.stats).forEach(([key, value]) => { + assert.deepStrictEqual( + report.data[key], + value, + `should match expected data for "${key}"` + ); + }); + } + + function verifyReports(data, options = {}) { + if (typeof data !== 'undefined') { + data.map((d) => verifyReport(d, options)); + } else { + reports.map(verifyReport); + } + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + declaration = null; + fakeClock = null; + requestSpies = testUtil.requestSpies(); + + pollerDestroytub = sinon.stub(SystemPoller.prototype, 'destroy'); + pollerDestroytub.callThrough(); + pollerStartStub = sinon.stub(SystemPoller.prototype, 'start'); + pollerStartStub.callThrough(); + pollerStopStub = sinon.stub(SystemPoller.prototype, 'stop'); + pollerStopStub.callThrough(); + + coreStub = stubs.default.coreStub(); + appEvents = coreStub.appEvents.appEvents; + configWorker = coreStub.configWorker.configWorker; + + dataPipeline = new DataPipeline(); + dataPipeline.initialize(appEvents); + + service = new SystemPollerService(); + service.initialize(appEvents); + + assert.deepStrictEqual(service.numberOfClassicPollers, 0); + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPassivePollers, 0); + + await dataPipeline.start(); + await service.start(); + await coreStub.startServices(); + + await Promise.all([ + appEvents.waitFor('systemPoller.config.applied'), + coreStub.configWorker.configWorker.load() + ]); + + assert.deepStrictEqual(service.numberOfClassicPollers, 0); + assert.deepStrictEqual(service.numberOfDemoPollers, 0); + assert.deepStrictEqual(service.numberOfPassivePollers, 0); + assert.isEmpty(coreStub.logger.messages.error); + + coreStub.logger.removeAllMessages(); + + reports = []; + service.ee.on('report', (report) => reports.push(report)); + }); + + afterEach(async () => { + if (fakeClock) { + await Promise.all([ + forwardClock(getTimeStep() * 100, () => service.isDestroyed()), + service.destroy() + ]); + } else { + await service.destroy(); + } + await dataPipeline.destroy(); + await coreStub.destroyServices(); + + assert.isTrue(service.isDestroyed()); + + testUtil.nockCleanup(); + sinon.restore(); + + verifyReports(); + + if (declaration) { + helpers.checkBigIpRequests(declaration, requestSpies); + } + }); + + describe('Resource Monitor', () => { + let resourceMonitor; + + beforeEach(async () => { + resourceMonitor = new ResourceMonitor(); + resourceMonitor.initialize(appEvents); + await resourceMonitor.start(); + }); + + afterEach(async () => { + await resourceMonitor.destroy(); + }); + + it('should enable/disable system pollers according to processing state', async () => { + declaration = helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + ); + declaration.namespace = dummies.declaration.namespace.base.decrypted(Object.assign( + { + consumer: dummies.declaration.consumer.default.decrypted({}) + }, + helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + ) + )); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC poller'); + + reports = []; + await forwardClock(getTimeStep(), () => reports.length >= 2); + + reports = []; + coreStub.resourceMonitorUtils.osAvailableMem.free = 10; + + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.warning, + /Temporarily disabling system poller/ + ); + return true; + }); + + assert.isEmpty(reports); + await fakeClock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }); + assert.isEmpty(reports); + + coreStub.resourceMonitorUtils.osAvailableMem.free = 9999; + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.warning, + /Enabling system poller/ + ); + return true; + }); + + await forwardClock(getTimeStep(), () => reports.length >= 2); + + reports = []; + coreStub.resourceMonitorUtils.osAvailableMem.free = 10; + + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.warning, + /Temporarily disabling system poller/ + ); + return true; + }); + + assert.isEmpty(reports); + await fakeClock.clockForward(3000, { promisify: true, delay: 1, repeat: 100 }); + assert.isEmpty(reports); + + coreStub.resourceMonitorUtils.osAvailableMem.free = 9999; + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.warning, + /Enabling system poller/ + ); + return true; + }); + + await forwardClock(getTimeStep(), () => reports.length >= 2); + }); + }); + + it('should enable processing by default', () => { + assert.isTrue(service.isProcessingEnabled()); + }); + + it('should unsubscribe from config updates once destroyed', async () => { + await processDeclaration(helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.isEmpty(coreStub.logger.messages.error); + assert.includeMatch( + coreStub.logger.messages.all, + /SystemPollerService.*Config "change" event/ + ); + + await service.destroy(); + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + + coreStub.logger.removeAllMessages(); + await processDeclaration({}, undefined, false); + + assert.notIncludeMatch( + coreStub.logger.messages.all, + /SystemPollerService.*Config "change" event/ + ); + + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + }); + + it('should wait till config update complete before proceed with service destroying', async () => { + const destroyPromise = new Promise((resolve) => { + pollerStartStub.callsFake(() => { + service.destroy().then(resolve); + }); + }); + + await processDeclaration(helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + )); + + await destroyPromise; + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /Waiting for config routine to finish/ + ); + }); + + it('should log error when unable to start poller', async () => { + pollerStartStub.rejects(new Error('expected poller start error')); + await processDeclaration(helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + assert.includeMatch( + coreStub.logger.messages.error, + /SystemPollerService.*Uncaught error on attempt to start.*systemPoller[\s\S]*expected poller start error/gm + ); + }); + + it('should log error when unable to destroy poller', async () => { + let poller; + pollerDestroytub.callsFake(function () { + poller = this; + throw new Error('expected poller destroy error'); + }); + await processDeclaration(helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + )); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + + await processDeclaration({}); + assert.includeMatch( + coreStub.logger.messages.error, + /SystemPollerService.*Uncaught error on attempt to destroy poller.*systemPoller[\s\S]*expected poller destroy error/gm + ); + pollerDestroytub.restore(); + await poller.destroy(); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + }); + + it('should log error when unable to destroy all pollers', async () => { + const pollers = []; + pollerDestroytub.callsFake(function () { + pollers.push(this); + throw new Error('expected poller destroy error'); + }); + + const decl = helpers.attachPoller( + helpers.systemPoller(), + helpers.system() + ); + decl.system2 = testUtil.deepCopy(decl.system); + + await processDeclaration(decl); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC pollers'); + + await service.destroy(); + assert.isTrue(service.isDestroyed()); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + + assert.includeMatch( + coreStub.logger.messages.error, + /SystemPollerService.*Uncaught error on attempt to destroy poller.*systemPoller[\s\S]*expected poller destroy error/gm + ); + pollerDestroytub.restore(); + await Promise.all(pollers.map((p) => p.destroy())); + }); + + describe('configuration variations', () => { + const combinations = testUtil.product( + // endpoints + [ + { + name: 'default', + value: false + }, + { + name: 'custom', + value: true + } + ], + // system auth + testUtil.smokeTests.filter([ + { + name: 'system without user', + value: undefined + }, + { + name: 'system with user and passphrase', + value: { username: true, passphrase: true } + } + ]), + // system connection + testUtil.smokeTests.filter([ + { + name: 'localhost system', + value: undefined + }, + { + name: 'remote system with non default config', + value: { + host: remotehost, + allowSelfSignedCert: true, + port: 8889, + protocol: 'https' + } + } + ]), + // namespace + [ + { + name: 'default', + value: undefined + }, + { + name: 'custom', + value: 'namespace' + } + ] + ); + + combinations.forEach(([endpointsConf, systemAuthConf, systemConf, namespaceConf]) => describe(`endpoints = ${endpointsConf.name}, system = ${systemConf.name}, systemAuth = ${systemAuthConf.name}, namespace = ${namespaceConf.name}`, + () => { + if (systemConf.value && systemConf.value.host === remotehost + && !(systemAuthConf.value && systemAuthConf.value.passphrase) + ) { + // password-less users are not supported by remote device + return; + } + + function getDeclaration(ival = 60, enable = true, trace = false) { + return helpers.getDeclaration({ + enable, + endpoints: endpointsConf.value, + interval: ival, + systemAuthConf, + systemConf, + trace + }); + } + + testUtil.product( + // enable + [ + { + name: 'enabled', + value: true + }, + { + name: 'disabled', + value: false + } + ], + // trace + [ + { + name: 'enabled', + value: true + }, + { + name: 'disabled', + value: false + } + ] + ).forEach(([enableConf, traceConf]) => it(`enable = ${enableConf.name}, trace = ${traceConf.name}`, async () => { + await processDeclaration( + getDeclaration(undefined, enableConf.value, traceConf.value), + namespaceConf.value + ); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, enableConf.value ? 1 : 0, 'should have expected number of CLASSIC pollers'); + assert.isEmpty(coreStub.logger.messages.error); + })); + + it('should process poller config', async () => { + await processDeclaration(getDeclaration(), namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.isEmpty(coreStub.logger.messages.error); + }); + + it('should not restart existing poller if no config changed', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.consumer = dummies.declaration.consumer.default.decrypted(); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const configID = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + const dstIds = configWorker.currentConfig.mappings[configID]; + + assert.isNotEmpty(configID, 'should find ID'); + assert.isNotEmpty(dstIds, 'should have receivers'); + + await forwardClock(getTimeStep(), () => reports.length > 2); + + reports.forEach((report) => { + assert.deepStrictEqual(report.sourceId, configID); + assert.deepStrictEqual(report.destinationIds, dstIds); + }); + + coreStub.logger.removeAllMessages(); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*systemPoller/ + ); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const newConfigID = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + const newDstIds = configWorker.currentConfig.mappings[newConfigID]; + + assert.isNotEmpty(newConfigID, 'should find ID'); + assert.isNotEmpty(newDstIds, 'should have receivers'); + assert.notDeepEqual(newConfigID, configID, 'should generated new ID'); + assert.notDeepEqual(dstIds, newDstIds, 'should update receivers'); + + const reportIdx = reports.length; + await forwardClock(getTimeStep(), () => reports.length > (reportIdx + 3)); + + reports.forEach((report, idx) => { + if (idx >= reportIdx) { + assert.deepStrictEqual(report.sourceId, newConfigID, 'should use new IDs'); + assert.deepStrictEqual(report.destinationIds, newDstIds, 'should use new IDs'); + } + }); + }); + + it('should restart existing poller when config changed', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.consumer = dummies.declaration.consumer.default.decrypted(); + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const configID = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + const dstIds = configWorker.currentConfig.mappings[configID]; + + assert.isNotEmpty(configID, 'should find ID'); + assert.isNotEmpty(dstIds, 'should have receivers'); + + await forwardClock(getTimeStep(), () => reports.length > 2); + + reports.forEach((report) => { + assert.deepStrictEqual(report.sourceId, configID); + assert.deepStrictEqual(report.destinationIds, dstIds); + }); + + declaration.systemPoller.interval = 400; + coreStub.logger.removeAllMessages(); + + await processDeclaration(declaration, namespaceConf.value); + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Poller.*system::systemPoller.*destroyed/ + ); + return true; + }); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*systemPoller.*Reason - configuration updated/ + ); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*systemPoller/ + ); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 1); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + const newConfigID = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + const newDstIds = configWorker.currentConfig.mappings[newConfigID]; + + assert.isNotEmpty(newConfigID, 'should find ID'); + assert.isNotEmpty(newDstIds, 'should have receivers'); + assert.notDeepEqual(newConfigID, configID, 'should generated new ID'); + assert.notDeepEqual(dstIds, newDstIds, 'should update receivers'); + + reports = []; + await forwardClock(getTimeStep(), () => reports.length >= 2); + + reports.forEach((report) => { + assert.deepStrictEqual(report.sourceId, newConfigID, 'should use new IDs'); + assert.deepStrictEqual(report.destinationIds, newDstIds, 'should use new IDs'); + }); + }); + + it('should destroy poller when removed from the declaration', async () => { + coreStub.logger.removeAllMessages(); + const decl = getDeclaration(); + + let declCopy = testUtil.deepCopy(decl); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + declCopy = testUtil.deepCopy(decl); + declCopy.system2 = testUtil.deepCopy(decl.system); + + coreStub.logger.removeAllMessages(); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*system::systemPoller/ + ); + + delete declCopy.system; + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*system2::systemPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Poller.*system::systemPoller.*destroyed/ + ); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC pollers'); + return true; + }, true); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 1); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + delete declCopy.system2; + delete declCopy.systemPoller; + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Poller.*system2::systemPoller.*destroyed/ + ); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 2); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + }); + + it('should destroy pollers when no consumers defined', async () => { + coreStub.logger.removeAllMessages(); + const decl = getDeclaration(); + + let declCopy = testUtil.deepCopy(decl); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*system::systemPoller.*WAITING/ + ); + return true; + }, true); + + declCopy = testUtil.deepCopy(decl); + declCopy.system2 = testUtil.deepCopy(decl.system); + + coreStub.logger.removeAllMessages(); + await processDeclaration(declCopy, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.notIncludeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*system::systemPoller/ + ); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*system2::systemPoller.*WAITING/ + ); + return true; + }, true); + + coreStub.logger.removeAllMessages(); + await processDeclaration(testUtil.deepCopy(declCopy), namespaceConf.value, true, false); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*system2::systemPoller.*Reason - configuration updated/ + ); + + await testUtil.waitTill(() => { + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have no CLASSIC pollers'); + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 2); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + }); + + it('should not restart existing poller if no config changed (new namespace created)', async () => { + const decl = getDeclaration(); + await processDeclaration(decl, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*::system::systemPoller.*WAITING/ + ); + return true; + }, true); + + coreStub.logger.removeAllMessages(); + + await processDeclaration(decl, 'namesapce-new'); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC pollers'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*::system::systemPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*namesapce-new::system::systemPoller.*WAITING/ + ); + return true; + }, true); + + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + coreStub.logger.removeAllMessages(); + await processDeclaration({}, 'namesapce-new'); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Poller.*namesapce-new::system::systemPoller.*destroyed/ + ); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*namesapce-new::system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*system::systemPoller/ + ); + }); + + if (namespaceConf.value) { + it('should not restart existing poller if no config changed (root namespace updated)', async () => { + const decl = getDeclaration(); + await processDeclaration(decl, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*system::systemPoller.*WAITING/ + ); + return true; + }, true); + + decl[namespaceConf.value] = dummies.declaration.namespace.base.decrypted(Object.assign( + {}, + testUtil.deepCopy(decl), + { consumer: dummies.declaration.consumer.default.decrypted({}) } + )); + + coreStub.logger.removeAllMessages(); + await processDeclaration(decl); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 2, 'should have 2 CLASSIC pollers'); + + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*namespace::system::systemPoller/ + ); + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*f5telemetry_default::system::systemPoller.*WAITING/ + ); + return true; + }, true); + + assert.deepStrictEqual(pollerStartStub.callCount, 2); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + + delete decl.system; + delete decl.systemPoller; + + coreStub.logger.removeAllMessages(); + await processDeclaration(decl); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.Poller.*f5telemetry_default::system::systemPoller.*destroyed/ + ); + return true; + }, true); + + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*Removing System Poller.*f5telemetry_default::system::systemPoller.*Reason - configuration updated/ + ); + assert.includeMatch( + coreStub.logger.messages.debug, + /SystemPollerService.*No configuration changes for.*namespace::system::systemPoller/ + ); + }); + } + + it('should start and finish polling cycle multipe times', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(reports); + + await forwardClock(getTimeStep(), () => reports.length > 2); + assert.isEmpty(coreStub.logger.messages.error); + }); + + if (systemAuthConf.value) { + it('should fail task when unable to decrypt config', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(reports); + + coreStub.deviceUtil.decrypt.rejects(new Error('expected decrypt error')); + + await forwardClock(getTimeStep(), () => { + assert.includeMatch( + coreStub.logger.messages.error, + /System Poller cycle failed due task error[\s\S]*expected decrypt error/gm + ); + return true; + }); + }); + } + + if (!endpointsConf.value) { + it('should apply post-polling filtering', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.actions[0].locations.system.provisioning = { + asm: true + }; + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC poller'); + assert.deepStrictEqual(pollerStartStub.callCount, 1); + assert.deepStrictEqual(pollerDestroytub.callCount, 0); + assert.deepStrictEqual(pollerStopStub.callCount, 0); + assert.isEmpty(reports); + + await forwardClock(getTimeStep(), () => reports.length >= 1); + + assert.deepStrictEqual(reports[0].data.system.provisioning, { + asm: { + level: 'nominal', + name: 'asm' + } + }); + + reports = []; + }); + } + + describe('passive polling', () => { + let passiveReports; + let pullService; + + function collect(id) { + return new Promise((resolve, reject) => { + pullService.emit('systemPoller.collect', id, (error, stats) => { + if (error) { + reject(error); + } else { + passiveReports.push(stats); + resolve(); + } + }); + }); + } + + beforeEach(() => { + passiveReports = []; + pullService = new SafeEventEmitter(); + appEvents.register(pullService, 'test', [ + 'systemPoller.collect' + ]); + }); + + it('should return error when not poller found', async () => { + await assert.isRejected(collect('unknownID'), /System Poller with ID "unknownID" not found/); + }); + + it('should collect stats using passive poller (0 interval)', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.interval = 0; + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have 0 CLASSIC poller'); + + const id = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + await Promise.all([ + collect(id), + forwardClock(100, () => passiveReports.length > 0) + ]); + + verifyReports(passiveReports, { interval: 0 }); + reports = []; + }); + + it('should collect stats using passive poller (300 interval)', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.interval = 300; + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 1, 'should have 1 CLASSIC pollers'); + + const id = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + await Promise.all([ + collect(id), + forwardClock(100, () => passiveReports.length > 0) + ]); + + verifyReports(passiveReports, { interval: 0 }); + reports = []; + }); + + it('should collect stats using passive poller (disabled)', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.enable = false; + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have 0 CLASSIC pollers'); + + const id = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + await Promise.all([ + collect(id), + forwardClock(100, () => passiveReports.length > 0) + ]); + + verifyReports(passiveReports, { interval: 0 }); + reports = []; + }); + + it('should throw error when unable to start poller', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.interval = 0; + + createPollerMock(declaration); + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have 0 CLASSIC pollers'); + + const id = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + pollerStartStub.rejects(new Error('expected start error')); + + await assert.isRejected(collect(id), /expected start error/); + }); + + it('should throw error when unable to collect stats', async () => { + coreStub.logger.removeAllMessages(); + + declaration = getDeclaration(); + declaration.systemPoller.interval = 0; + + const pollerMock = createPollerMock(declaration); + (endpointsConf.value + ? pollerMock.getCustomVirtualsStub() + : pollerMock.getSysReadyStub() + ).returns(false); + + fakeClock = stubs.clock({ fakeTimersOpts: Date.now() }); + + await processDeclaration(declaration, namespaceConf.value); + assert.deepStrictEqual(service.numberOfPassivePollers, 0, 'should have no PASSIVE pollers'); + assert.deepStrictEqual(service.numberOfDemoPollers, 0, 'should have no DEMO pollers'); + assert.deepStrictEqual(service.numberOfClassicPollers, 0, 'should have 0 CLASSIC pollers'); + + const id = configWorker.currentConfig.components.find((c) => c.name === 'systemPoller').id; + + let done = false; + await Promise.all([ + (async () => { + await assert.isRejected(collect(id), /Poller.collectStats: unable to collect stats/); + done = true; + })(), + forwardClock(100, () => done) + ]); + }); + }); + })); + }); +}); diff --git a/test/unit/systemPoller/utilsTests.js b/test/unit/systemPoller/utilsTests.js new file mode 100644 index 00000000..34169cf2 --- /dev/null +++ b/test/unit/systemPoller/utilsTests.js @@ -0,0 +1,122 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const assert = require('../shared/assert'); +const filterStatsUtilTestsData = require('./data/filterStatsTestsData'); +const sourceCode = require('../shared/sourceCode'); +const systemStatsUtilTestsData = require('./data/utilsTestsData'); +const testUtil = require('../shared/util'); + +const defaultProperties = sourceCode('src/lib/properties.json'); +const systemStatsUtil = sourceCode('src/lib/systemPoller/utils'); + +moduleCache.remember(); + +describe('System Poller / Utils', () => { + before(() => { + moduleCache.restore(); + }); + + describe('.renderProperty()', () => { + systemStatsUtilTestsData.renderProperty.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, () => { + const contextStateValidator = testUtil.getSpoiledDataValidator(testConf.contextData); + const propertyCopy = testUtil.deepCopy(testConf.propertyData); + + const promise = new Promise((resolve, reject) => { + try { + resolve(systemStatsUtil.renderProperty(testConf.contextData, propertyCopy)); + } catch (err) { + reject(err); + } + }); + if (testConf.errorMessage) { + return assert.isRejected(promise, testConf.errorMessage); + } + return promise.then((result) => { + assert.deepStrictEqual(result, testConf.expectedData, 'should match expected data'); + assert.deepStrictEqual(result, propertyCopy, 'should modify property in place'); + contextStateValidator(); + }); + }); + }); + }); + + describe('.splitKey()', () => { + systemStatsUtilTestsData.splitKey.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, () => assert.deepStrictEqual( + systemStatsUtil.splitKey(testConf.key), + testConf.expected + )); + }); + }); + + describe('.filterStats()', () => { + filterStatsUtilTestsData.filterStats.forEach((testConf) => { + testUtil.getCallableIt(testConf)(testConf.name, () => { + const stats = typeof testConf.customEndpoints !== 'undefined' ? testConf.customEndpoints : defaultProperties.stats; + const dataStateValidator = testUtil.getSpoiledDataValidator(stats); + const filteredStats = systemStatsUtil.filterStats(stats, testConf.actions, !!testConf.skipTMStats); + + const activeStats = Object.keys(filteredStats); + + // not strict, just verifies that properties are not in skip list + // so, if property in skip list -> it is an error + const shouldKeep = (testConf.shouldKeep || testConf.shouldKeepOnly || []).filter( + (statKey) => activeStats.indexOf(statKey) === -1 + ); + assert.isEmpty(shouldKeep, `[shouldKeep] should keep following properties - '${JSON.stringify(shouldKeep)}'`); + + // not strict, just verifies that properties are in skip list + // so, if property not in skip list -> it is an error + const shouldRemove = (testConf.shouldRemove || testConf.shouldRemoveOnly || []).filter( + // stats key SHOULD be in skip list + (statKey) => activeStats.indexOf(statKey) !== -1 + ); + assert.isEmpty(shouldRemove, `[shouldRemove] should remove following properties - '${JSON.stringify(shouldRemove)}'`); + + // strict, that only certain properties are presented. + // [] (empty array) - means 'keep nothing' + let notRemoved = []; + if (testConf.shouldKeepOnly) { + notRemoved = Object.keys(stats).filter( + (statKey) => activeStats.indexOf(statKey) !== -1 + && testConf.shouldKeepOnly.indexOf(statKey) === -1 + ); + } + assert.isEmpty(notRemoved, `[shouldKeepOnly] should remove following properties - '${JSON.stringify(notRemoved)}'`); + + // strict, verifies only that properties are removed. + // [] (empty array) - means 'remove nothing' + let notKept = []; + if (testConf.shouldRemoveOnly) { + notKept = Object.keys(stats).filter( + (statKey) => activeStats.indexOf(statKey) === -1 + && testConf.shouldRemoveOnly.indexOf(statKey) === -1 + ); + } + assert.isEmpty(notKept, `[shouldRemoveOnly] should keep following properties - '${JSON.stringify(notKept)}'`); + + dataStateValidator(); + }); + }); + }); +}); diff --git a/test/unit/systemPollerTests.js b/test/unit/systemPollerTests.js deleted file mode 100644 index ad0f9a1a..00000000 --- a/test/unit/systemPollerTests.js +++ /dev/null @@ -1,810 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); -const systemPollerConfigTestsData = require('./data/systemPollerTestsData'); -const testUtil = require('./shared/util'); - -const configWorker = sourceCode('src/lib/config'); -const ResourceMonitor = sourceCode('src/lib/resourceMonitor'); -const systemPoller = sourceCode('src/lib/systemPoller'); -const SystemStats = sourceCode('src/lib/systemStats'); -const tracerMgr = sourceCode('src/lib/tracerManager'); - -moduleCache.remember(); - -describe('System Poller', () => { - let coreStub; - - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - coreStub = stubs.default.coreStub(); - coreStub.utilMisc.generateUuid.numbersOnly = false; - }); - - afterEach(() => configWorker.processDeclaration({ class: 'Telemetry' }) - .then(() => sinon.restore())); - - describe('.safeProcess()', () => { - let config; - let returnCtx; - let sinonClock; - - beforeEach(() => { - sinonClock = sinon.useFakeTimers(); - config = { - dataOpts: { - actions: [] - }, - interval: 100, - id: 'mockId', - destinationIds: ['mockDestId'] - }; - returnCtx = null; - - sinon.stub(SystemStats.prototype, 'collect').callsFake(() => { - if (typeof returnCtx === 'object') { - return Promise.resolve(testUtil.deepCopy(returnCtx)); - } - return returnCtx(); - }); - }); - - afterEach(() => { - sinonClock.restore(); - }); - - it('should fail when .process rejects promise (requestFromUser)', () => { - returnCtx = () => Promise.reject(new Error('some error')); - return assert.isRejected( - systemPoller.safeProcess(config, { requestFromUser: true }), - /some error/ - ); - }); - - it('should not fail when .process rejects promise (background process)', () => { - returnCtx = () => Promise.reject(new Error('some error')); - return assert.isFulfilled(systemPoller.safeProcess(config)); - }); - - it('should fail when .process throws error (requestFromUser)', () => { - sinon.stub(systemPoller, 'process').throws(new Error('some error')); - return assert.isRejected( - systemPoller.safeProcess(config, { requestFromUser: true }), - /systemPoller:safeProcess unhandled exception.*some error/ - ); - }); - - it('should not fail when .process throws error (background process)', () => { - sinon.stub(systemPoller, 'process').throws(new Error('some error')); - return assert.isFulfilled(systemPoller.safeProcess(config)); - }); - - it('should resolve with data', () => { - // thanks to fakeTimers - Date returns the same data - const dataString = (new Date()).toISOString(); - returnCtx = () => Promise.resolve({ data: 'data' }); - return assert.becomes( - systemPoller.safeProcess(config, { requestFromUser: true }), - { - data: { - data: 'data', - telemetryEventCategory: 'systemInfo', - telemetryServiceInfo: { - cycleStart: dataString, - cycleEnd: dataString, - pollingInterval: 100 - } - }, - isCustom: undefined, - type: 'systemInfo', - sourceId: 'mockId', - destinationIds: ['mockDestId'] - } - ); - }); - }); - - describe('.getPollersConfig', () => { - /* eslint-disable implicit-arrow-linebreak */ - systemPollerConfigTestsData.getPollersConfig.forEach((testConf) => - testUtil.getCallableIt(testConf)(testConf.name, () => configWorker.processDeclaration(testUtil.deepCopy( - testConf.declaration - )) - .then(() => systemPoller.getPollersConfig(testConf.sysOrPollerName, testConf.funcOptions)) - .then((pollersConfig) => { - pollersConfig = pollersConfig.map((p) => ({ name: p.traceName })); - assert.sameDeepMembers(pollersConfig, testConf.expectedConfig); - // assert.isTrue(coreStub.deviceUtil.decryptSecret.called); - }, (error) => { - if (testConf.errorRegExp) { - return assert.match(error, testConf.errorRegExp, 'should match expected error message'); - } - return Promise.reject(error); - }))); - }); - - describe('.findSystemOrPollerConfigs', () => { - /* eslint-disable implicit-arrow-linebreak */ - systemPollerConfigTestsData.findSystemOrPollerConfigs.forEach((testConf) => - testUtil.getCallableIt(testConf)(testConf.name, () => configWorker.processDeclaration(testUtil.deepCopy( - testConf.rawConfig - )) - .then(() => { - let actual; - try { - actual = systemPoller.findSystemOrPollerConfigs( - configWorker.currentConfig, - testConf.sysOrPollerName, - testConf.pollerName, - testConf.namespaceName - ); - } catch (err) { - actual = err.message; - } - assert.deepStrictEqual(actual, testConf.expected); - }))); - }); - - describe('.fetchPollersData', () => { - let processStub; - - beforeEach(() => { - processStub = sinon.stub(systemPoller, 'process'); - processStub.callsFake((config) => Promise.resolve({ data: { poller: config.name } })); - }); - - it('should return empty array when no config passed', () => { - const pollerConfigs = []; - const expected = []; - return assert.becomes(systemPoller.fetchPollersData(pollerConfigs), expected); - }); - - it('should fetch data using poller config', () => { - const pollerConfigs = [ - { - name: 'my_poller' - } - ]; - const expected = [ - { - data: { - poller: 'my_poller' - } - } - ]; - return assert.becomes(systemPoller.fetchPollersData(pollerConfigs), expected); - }); - - it('should fetch data using multiple poller configs', () => { - const pollerConfigs = [ - { - name: 'my_poller' - }, - { - name: 'my_super_poller' - } - ]; - const expected = [ - { - data: { - poller: 'my_poller' - } - }, - { - data: { - poller: 'my_super_poller' - } - } - ]; - return assert.becomes(systemPoller.fetchPollersData(pollerConfigs), expected); - }); - - it('should reject when unable to fetch data', () => { - const pollerConfigs = [{ name: 'my_poller' }]; - processStub.rejects(new Error('testError')); - return assert.isRejected(systemPoller.fetchPollersData(pollerConfigs), 'testError'); - }); - - it('should NOT decrypt secrets when decryptSecrets is not specified', () => { - const pollerConfigs = [ - { - name: 'i_dont_remember' - } - ]; - return systemPoller.fetchPollersData(pollerConfigs) - .then(() => assert.isFalse(coreStub.deviceUtil.decryptSecret.called)); - }); - - it('should decrypt secrets when decryptSecrets=true', () => { - const pollerConfigs = [ - { - name: 'my_poller', - passphrase: { - class: 'Secret', - cipherText: '$M$test' - } - }, - { - name: 'my_other_poller', - passphrase: { - class: 'Secret', - cipherText: '$M$test' - } - } - ]; - return systemPoller.fetchPollersData(pollerConfigs, true) - .then(() => assert.isTrue(coreStub.deviceUtil.decryptSecret.called)); - }); - - it('should NOT decrypt secrets when decryptSecrets=false', () => { - const pollerConfigs = [ - { - name: 'poller_alone' - } - ]; - return systemPoller.fetchPollersData(pollerConfigs, false) - .then(() => assert.isFalse(coreStub.deviceUtil.decryptSecret.called)); - }); - }); - - describe('events', () => { - beforeEach(() => { - stubs.clock(); - }); - - afterEach(() => configWorker.processDeclaration({ class: 'Telemetry' }) - .then(() => { - assert.deepStrictEqual(systemPoller.getPollerTimers(), {}); - })); - - describe('config "on change" event', () => { - const defaultDeclaration = { - class: 'Telemetry', - My_System: { - class: 'Telemetry_System', - trace: true, - systemPoller: { - interval: 180 - } - } - }; - - let pollerTimers; - let pollerTimersBefore; - - const registeredTracerPaths = () => { - const paths = tracerMgr.registered().map((t) => t.path); - paths.sort(); - return paths; - }; - - beforeEach(() => { - pollerTimers = {}; - sinon.stub(systemPoller, 'getPollerTimers').returns(pollerTimers); - return configWorker.processDeclaration(testUtil.deepCopy(defaultDeclaration)) - .then(() => { - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.args[0].traceName, 'f5telemetry_default::My_System::SystemPoller_1'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].config.traceName, 'f5telemetry_default::My_System::SystemPoller_1'); - assert.lengthOf(tracerMgr.registered(), 1); - assert.sameOrderedMatches(registeredTracerPaths(), ['Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1']); - pollerTimersBefore = Object.assign({}, pollerTimers); - }); - }); - - it('should stop existing poller(s) when removed from config', () => configWorker.emitAsync('change', { components: [], mappings: {} }) - .then(() => { - assert.deepStrictEqual(pollerTimers, {}); - assert.isEmpty(tracerMgr.registered()); - assert.isFalse(pollerTimersBefore['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - })); - - it('should update existing poller(s)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System.systemPoller.interval = 500; - newDeclaration.My_System.systemPoller.trace = true; - const expectedPollerConfig = { - name: 'SystemPoller_1', - traceName: 'f5telemetry_default::My_System::SystemPoller_1', - systemName: 'My_System', - id: 'f5telemetry_default::My_System::SystemPoller_1', - namespace: 'f5telemetry_default', - class: 'Telemetry_System_Poller', - enable: true, - interval: 500, - trace: { - enable: true, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1', - type: 'output' - }, - tracer: tracerMgr.registered()[0], - credentials: { - username: undefined, - passphrase: undefined - }, - connection: { - allowSelfSignedCert: false, - host: 'localhost', - port: 8100, - protocol: 'http' - }, - dataOpts: { - noTMStats: true, - tags: undefined, - actions: [ - { - enable: true, - setTag: { - application: '`A`', - tenant: '`T`' - } - } - ] - }, - destinationIds: [] - }; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.lengthOf(tracerMgr.registered(), 1); - assert.sameOrderedMatches(registeredTracerPaths(), ['Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1']); - assert.deepStrictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].config, expectedPollerConfig); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 500, 'should set configured interval'); - assert.deepStrictEqual( - pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.args, - [expectedPollerConfig], - 'should pass configuration as arg' - ); - }); - }); - - it('should ignore disabled pollers (existing poller)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System.enable = false; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.deepStrictEqual(pollerTimers, {}); - assert.isEmpty(tracerMgr.registered()); - assert.isFalse(pollerTimersBefore['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - }); - }); - - it('should ignore disabled pollers (non-existing poller)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System_New = testUtil.deepCopy(newDeclaration.My_System); - newDeclaration.My_System_New.enable = false; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.lengthOf(Object.keys(pollerTimers), 1); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].config.traceName, 'f5telemetry_default::My_System::SystemPoller_1'); - assert.lengthOf(tracerMgr.registered(), 1); - assert.sameOrderedMatches(registeredTracerPaths(), ['Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1']); - }); - }); - - it('should stop poller removed from System (existing poller with same system)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - delete newDeclaration.My_System.systemPoller; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.deepStrictEqual(pollerTimers, {}); - assert.isEmpty(tracerMgr.registered()); - assert.isFalse(pollerTimersBefore['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - }); - }); - - it('should ignore System without poller (non-existing poller)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System_New = testUtil.deepCopy(newDeclaration.My_System); - delete newDeclaration.My_System_New.systemPoller; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.lengthOf(Object.keys(pollerTimers), 1); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].config.traceName, 'f5telemetry_default::My_System::SystemPoller_1'); - assert.lengthOf(tracerMgr.registered(), 1); - assert.sameOrderedMatches(registeredTracerPaths(), ['Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1']); - }); - }); - - it('should start new poller (non-existing poller, inline declaration)', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System_New = testUtil.deepCopy(newDeclaration.My_System); - newDeclaration.My_System_New.trace = false; - newDeclaration.My_System_New.systemPoller.interval = 500; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.lengthOf(Object.keys(pollerTimers), 2); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].config.traceName, 'f5telemetry_default::My_System::SystemPoller_1'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].timer.intervalInS, 500, 'should set configured interval'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].config.traceName, 'f5telemetry_default::My_System_New::SystemPoller_1'); - assert.lengthOf(tracerMgr.registered(), 1); - assert.sameOrderedMatches(registeredTracerPaths(), ['Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1']); - }); - }); - - it('should handle multiple pollers per system', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System_New = testUtil.deepCopy(newDeclaration.My_System); - newDeclaration.My_System_New.systemPoller = [ - { - interval: 10, - endpointList: { - basePath: 'mgmt/', - items: { - endpoint1: { - path: 'ltm/pool' - } - } - } - }, - 'My_Poller' - ]; - newDeclaration.My_Poller = { - class: 'Telemetry_System_Poller', - trace: true, - interval: 500 - }; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.lengthOf(Object.keys(pollerTimers), 3); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].timer.intervalInS, 10, 'should set configured interval'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System_New::My_Poller'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System_New::My_Poller'].timer.intervalInS, 500, 'should set configured interval'); - assert.sameOrderedMatches(registeredTracerPaths(), [ - 'Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1', - 'Telemetry_System_Poller.f5telemetry_default::My_System_New::My_Poller', - 'Telemetry_System_Poller.f5telemetry_default::My_System_New::SystemPoller_1' - ]); - assert.deepStrictEqual( - pollerTimers['f5telemetry_default::My_System_New::My_Poller'].timer.args, - [ - { - id: 'f5telemetry_default::My_System_New::My_Poller', - name: 'My_Poller', - namespace: 'f5telemetry_default', - class: 'Telemetry_System_Poller', - systemName: 'My_System_New', - traceName: 'f5telemetry_default::My_System_New::My_Poller', - enable: true, - interval: 500, - trace: { - enable: true, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_System_Poller.f5telemetry_default::My_System_New::My_Poller', - type: 'output' - }, - tracer: tracerMgr.registered().find((t) => /Telemetry_System_Poller.f5telemetry_default::My_System_New::My_Poller/.test(t.path)), - credentials: { - username: undefined, - passphrase: undefined - }, - connection: { - allowSelfSignedCert: false, - host: 'localhost', - port: 8100, - protocol: 'http' - }, - dataOpts: { - noTMStats: true, - tags: undefined, - actions: [ - { - enable: true, - setTag: { - application: '`A`', - tenant: '`T`' - } - } - ] - }, - destinationIds: [] - } - ], - 'should pass configuration as arg' - ); - assert.deepStrictEqual( - pollerTimers['f5telemetry_default::My_System_New::SystemPoller_1'].timer.args, - [ - { - name: 'SystemPoller_1', - id: 'f5telemetry_default::My_System_New::SystemPoller_1', - namespace: 'f5telemetry_default', - class: 'Telemetry_System_Poller', - enable: true, - interval: 10, - trace: { - enable: true, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_System_Poller.f5telemetry_default::My_System_New::SystemPoller_1', - type: 'output' - }, - systemName: 'My_System_New', - tracer: tracerMgr.registered().find((t) => /Telemetry_System_Poller.f5telemetry_default::My_System_New::SystemPoller_1/.test(t.path)), - traceName: 'f5telemetry_default::My_System_New::SystemPoller_1', - credentials: { - username: undefined, - passphrase: undefined - }, - connection: { - allowSelfSignedCert: false, - host: 'localhost', - port: 8100, - protocol: 'http' - }, - dataOpts: { - noTMStats: true, - tags: undefined, - actions: [ - { - enable: true, - setTag: { - application: '`A`', - tenant: '`T`' - } - } - ] - }, - endpoints: { - endpoint1: { - enable: true, - name: 'endpoint1', - path: '/mgmt/ltm/pool', - protocol: 'http' - } - }, - destinationIds: [] - } - ], - 'should pass configuration as arg' - ); - }); - }); - - it('should fetch TMStats', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_Consumer = { - class: 'Telemetry_Consumer', - type: 'Splunk', - host: '192.168.2.1', - protocol: 'https', - port: 8088, - format: 'legacy', - passphrase: { - cipherText: '$M$Q7$xYs5xGCgf6Hlxsjd5AScwQ==', - class: 'Secret', - protected: 'SecureVault' - } - }; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual( - pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.args[0].dataOpts.noTMStats, - false, - 'should enable TMStats' - ); - }); - }); - - it('should clear existing interval when declaration has interval=0', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System.systemPoller.interval = 0; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.isFalse(pollerTimersBefore['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - assert.deepStrictEqual(pollerTimers, { 'f5telemetry_default::My_System::SystemPoller_1': undefined }); - }); - }); - - it('should update non-scheduled, enabled, System Pollers, when setting interval', () => { - const newDeclaration = testUtil.deepCopy(defaultDeclaration); - newDeclaration.My_System.systemPoller.interval = 200; - return configWorker.processDeclaration(testUtil.deepCopy(newDeclaration)) - .then(() => { - assert.isTrue(pollerTimersBefore['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 200); - assert.lengthOf(Object.keys(pollerTimers), 1); - assert.deepStrictEqual( - pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.args, - [ - { - name: 'SystemPoller_1', - traceName: 'f5telemetry_default::My_System::SystemPoller_1', - systemName: 'My_System', - id: 'f5telemetry_default::My_System::SystemPoller_1', - namespace: 'f5telemetry_default', - class: 'Telemetry_System_Poller', - enable: true, - interval: 200, - trace: { - enable: true, - encoding: 'utf8', - maxRecords: 10, - path: '/var/tmp/telemetry/Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1', - type: 'output' - }, - tracer: tracerMgr.registered()[0], - credentials: { - username: undefined, - passphrase: undefined - }, - connection: { - allowSelfSignedCert: false, - host: 'localhost', - port: 8100, - protocol: 'http' - }, - dataOpts: { - noTMStats: true, - tags: undefined, - actions: [ - { - enable: true, - setTag: { - application: '`A`', - tenant: '`T`' - } - } - ] - }, - destinationIds: [] - } - ], - 'should pass updated configuration as arg' - ); - }); - }); - - it('should start new poller without restarting existing one when processing a new namespace declaration', () => { - const newNamespace = { - class: 'Telemetry_Namespace', - My_System: { - class: 'Telemetry_System', - trace: true, - systemPoller: { - interval: 500 - } - } - }; - const updateSpy = sinon.spy(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer, 'update'); - return configWorker.processNamespaceDeclaration(testUtil.deepCopy(newNamespace), 'NewNamespace') - .then(() => { - assert.lengthOf(Object.keys(pollerTimers), 2); - assert.strictEqual(updateSpy.callCount, 0, 'should not call updated'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.isTrue(pollerTimers['NewNamespace::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['NewNamespace::My_System::SystemPoller_1'].timer.intervalInS, 500, 'should set configured interval'); - assert.sameOrderedMatches(registeredTracerPaths(), [ - 'Telemetry_System_Poller.NewNamespace::My_System::SystemPoller_1', - 'Telemetry_System_Poller.f5telemetry_default::My_System::SystemPoller_1' - ]); - }); - }); - }); - - describe('Resource Monitor', () => { - const defaultDeclaration = { - class: 'Telemetry', - My_System: { - class: 'Telemetry_System', - trace: true, - systemPoller: [ - { - interval: 180 - }, - { - interval: 200 - } - ] - }, - My_Poller: { - class: 'Telemetry_System_Poller', - interval: 0 - } - }; - - let clock; - let pollerTimers; - let resourceMonitor; - - beforeEach(() => { - clock = stubs.clock(); - resourceMonitor = new ResourceMonitor(); - - const appCtx = { - configMgr: configWorker, - resourceMonitor - }; - - resourceMonitor.initialize(appCtx); - systemPoller.initialize(appCtx); - - pollerTimers = {}; - sinon.stub(systemPoller, 'getPollerTimers').returns(pollerTimers); - - return resourceMonitor.start() - .then(() => Promise.all([ - configWorker.processDeclaration(testUtil.deepCopy(defaultDeclaration)), - clock.clockForward(3000, { promisify: true, delay: 1, repeat: 30 }) - ])) - .then(() => { - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_2'].timer.isActive(), 'should be active'); - }); - }); - - afterEach(() => resourceMonitor.destroy()); - - it('should disable running pollers when thresholds not ok', () => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 10; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - .then(() => { - assert.isFalse(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - assert.isFalse(pollerTimers['f5telemetry_default::My_System::SystemPoller_2'].timer.isActive(), 'should be inactive'); - assert.isFalse(systemPoller.isEnabled(), 'should set processingEnabled to false'); - }); - }); - - it('should enable disabled pollers when thresholds become ok', () => { - coreStub.resourceMonitorUtils.osAvailableMem.free = 10; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }) - .then(() => { - assert.isFalse(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be inactive'); - assert.isFalse(pollerTimers['f5telemetry_default::My_System::SystemPoller_2'].timer.isActive(), 'should be inactive'); - assert.isFalse(systemPoller.isEnabled(), 'should set processingEnabled to false'); - coreStub.resourceMonitorUtils.osAvailableMem.free = 500; - return clock.clockForward(3000, { promisify: true, delay: 1, repeat: 10 }); - }) - .then(() => { - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_1'].timer.intervalInS, 180, 'should set configured interval'); - assert.isTrue(pollerTimers['f5telemetry_default::My_System::SystemPoller_2'].timer.isActive(), 'should be active'); - assert.strictEqual(pollerTimers['f5telemetry_default::My_System::SystemPoller_2'].timer.intervalInS, 200, 'should set configured interval'); - assert.isTrue(systemPoller.isEnabled(), 'should set processingEnabled to true'); - }); - }); - }); - }); -}); diff --git a/test/unit/systemStatsTests.js b/test/unit/systemStatsTests.js deleted file mode 100644 index 1aa72755..00000000 --- a/test/unit/systemStatsTests.js +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); - -const assert = require('./shared/assert'); -const systemStatsTestsData = require('./data/systemStatsTestsData'); -const sourceCode = require('./shared/sourceCode'); -const testUtil = require('./shared/util'); - -const defaultProperties = sourceCode('src/lib/properties.json'); -const SystemStats = sourceCode('src/lib/systemStats'); - -const defaultPropertiesStateValidator = testUtil.getSpoiledDataValidator(defaultProperties); - -moduleCache.remember(); - -describe('System Stats', () => { - before(() => { - moduleCache.restore(); - }); - - afterEach(() => { - defaultPropertiesStateValidator(); - }); - - describe('.processData', () => { - let sysStats; - - beforeEach(() => { - sysStats = new SystemStats(); - }); - - it('should skip normalization', () => { - const property = { - key: 'propKey', - normalize: false - }; - const data = { - kind: 'dataKind' - }; - const key = 'keyForData'; - const expected = { - kind: 'dataKind' - }; - const result = sysStats._processData(property, data, key); - assert.deepStrictEqual(result, expected); - }); - - it('should normalize data', () => { - const property = { - key: 'propKey', - normalization: [ - { - convertArrayToMap: { keyName: 'name', keyNamePrefix: 'name/' } - }, - { - includeFirstEntry: '' - }, - { - filterKeys: { exclude: ['fullPath'] } - }, - { - renameKeys: { patterns: { prop1: { pattern: 'prop' } } } - }, - { - runFunctions: [] - }, - { - addKeysByTag: { skip: ['something'] } - } - ] - }; - const data = { - 'tenant/app/item': { - prop1: 'someData', - prop2: 'someMoreData', - fullPath: 'the/full/path' - } - }; - const key = 'keyValue'; - const expected = { - 'tenant/app/item': { - prop: 'someData', - prop2: 'someMoreData', - name: 'tenant/app/item' - } - }; - const result = sysStats._processData(property, data, key); - assert.deepStrictEqual(result, expected); - }); - - it('should normalize data and use defaults without normalization array', () => { - const property = { - key: 'propKey' - }; - const data = { - 'tenant~app~name': { - prop: 'value', - kind: 'dataKind', - selfLink: '/link/to/self' - } - }; - const key = 'keyValue'; - const expected = { - 'tenant/app/name': { - prop: 'value' - } - }; - const result = sysStats._processData(property, data, key); - assert.deepStrictEqual(result, expected); - }); - - it('should normalize data and use defaults with normalization array', () => { - const property = { - key: 'propKey', - normalization: [ - { - addKeysByTag: { skip: ['prop2'] } - } - ] - }; - const data = { - 'tenant~app~something': { - prop: 'value', - kind: 'dataKind', - selfLink: '/link/to/self' - } - }; - const key = 'keyValue'; - const expected = { - 'tenant/app/something': { - prop: 'value', - name: 'tenant/app/something' - } - }; - - const result = sysStats._processData(property, data, key); - assert.deepStrictEqual(result, expected); - }); - - it('should not apply default normalization to custom property', () => { - const property = { - key: 'propKey', - isCustom: true - }; - const data = { - 'tenant~app~something': { - prop: 'value', - kind: 'dataKind', - selfLink: '/link/to/self' - } - }; - const key = 'keyValue'; - const expected = { - 'tenant~app~something': { - prop: 'value', - kind: 'dataKind', - selfLink: '/link/to/self' - } - }; - const dataStateValidator = testUtil.getSpoiledDataValidator(property); - - const result = sysStats._processData(property, data, key); - assert.deepStrictEqual(result, expected); - dataStateValidator(); - }); - }); - - describe('._filterStats', () => { - systemStatsTestsData._filterStats.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => { - const stats = typeof testConf.customEndpoints !== 'undefined' ? testConf.customEndpoints : defaultProperties.stats; - const dataStateValidator = testUtil.getSpoiledDataValidator(stats); - - const systemStats = new SystemStats({ - dataOpts: { - actions: testConf.actions, - noTMStats: testConf.skipTMStats - }, - endpoints: testConf.customEndpoints - }); - systemStats._filterStats(); - - const activeStats = Object.keys(systemStats.isCustom ? systemStats.endpoints : systemStats.stats); - - // not strict, just verifies that properties are not in skip list - // so, if property in skip list -> it is an error - const shouldKeep = (testConf.shouldKeep || testConf.shouldKeepOnly || []).filter( - (statKey) => activeStats.indexOf(statKey) === -1 - ); - assert.isEmpty(shouldKeep, `[shouldKeep] should keep following properties - '${JSON.stringify(shouldKeep)}'`); - - // not strict, just verifies that properties are in skip list - // so, if property not in skip list -> it is an error - const shouldRemove = (testConf.shouldRemove || testConf.shouldRemoveOnly || []).filter( - // stats key SHOULD be in skip list - (statKey) => activeStats.indexOf(statKey) !== -1 - ); - assert.isEmpty(shouldRemove, `[shouldRemove] should remove following properties - '${JSON.stringify(shouldRemove)}'`); - - // strict, that only certain properties are presented. - // [] (empty array) - means 'keep nothing' - let notRemoved = []; - if (testConf.shouldKeepOnly) { - notRemoved = Object.keys(stats).filter( - (statKey) => activeStats.indexOf(statKey) !== -1 - && testConf.shouldKeepOnly.indexOf(statKey) === -1 - ); - } - assert.isEmpty(notRemoved, `[shouldKeepOnly] should remove following properties - '${JSON.stringify(notRemoved)}'`); - - // strict, verifies only that properties are removed. - // [] (empty array) - means 'remove nothing' - let notKept = []; - if (testConf.shouldRemoveOnly) { - notKept = Object.keys(stats).filter( - (statKey) => activeStats.indexOf(statKey) === -1 - && testConf.shouldRemoveOnly.indexOf(statKey) === -1 - ); - } - assert.isEmpty(notKept, `[shouldRemoveOnly] should keep following properties - '${JSON.stringify(notKept)}'`); - - dataStateValidator(); - }); - }); - }); - - describe('._processProperty()', () => { - it('should return empty promise when disabled', () => { - const systemStats = new SystemStats({ dataOpts: { noTMStats: true } }); - const property = { - disabled: true - }; - const dataStateValidator = testUtil.getSpoiledDataValidator(property); - return systemStats._processProperty('', property) - .then(() => { - assert.deepStrictEqual(systemStats.collectedData, {}); - dataStateValidator(); - }); - }); - - it('should add theKey to collectedData', () => { - const systemStats = new SystemStats(); - const property = { - structure: { - folder: true - } - }; - const dataStateValidator = testUtil.getSpoiledDataValidator(property); - return systemStats._processProperty('theKey', property) - .then(() => { - assert.deepStrictEqual(systemStats.collectedData.theKey, {}); - dataStateValidator(); - }); - }); - - it('should add to collectedData', () => { - const systemStats = new SystemStats(); - const property = { - key: 'theKey' - }; - const expected = { - theKey: { - key: 'theKey' - } - }; - sinon.stub(systemStats, '_loadData').resolves(property); - const dataStateValidator = testUtil.getSpoiledDataValidator(property); - return systemStats._processProperty('theKey', property) - .then(() => { - assert.deepStrictEqual(systemStats.collectedData, expected); - dataStateValidator(); - }); - }); - }); -}); diff --git a/test/unit/teemReporter/teemReporterTests.js b/test/unit/teemReporter/teemReporterTests.js new file mode 100644 index 00000000..6258c7af --- /dev/null +++ b/test/unit/teemReporter/teemReporterTests.js @@ -0,0 +1,312 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-use-before-define */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); +const TeemDevice = require('@f5devcentral/f5-teem').Device; + +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); + +const appInfo = sourceCode('src/lib/appInfo'); +const TeemReporterService = sourceCode('src/lib/teemReporter'); + +moduleCache.remember(); + +describe('Teem Reporter / Teem Reporter Service', () => { + let configWorker; + let coreStub; + let teemRecords; + let teemReportStub; + let teemService; + + function processDeclaration(decl) { + return Promise.all([ + configWorker.processDeclaration(decl), + coreStub.appEvents.appEvents.waitFor('teem.reported') + ]); + } + + before(() => { + moduleCache.restore(); + }); + + beforeEach(async () => { + coreStub = stubs.default.coreStub(); + coreStub.utilMisc.generateUuid.numbersOnly = false; + + configWorker = coreStub.configWorker.configWorker; + + teemRecords = []; + + teemService = new TeemReporterService(); + teemService.initialize(coreStub.appEvents.appEvents); + + teemReportStub = sinon.stub(TeemDevice.prototype, 'reportRecord'); + teemReportStub.callsFake(function (record) { + teemRecords.push({ info: this.assetInfo, record: record.recordBody }); + return Promise.resolve(); + }); + + await coreStub.startServices(); + await configWorker.cleanup(); + await teemService.start(); + + assert.isTrue(teemService.isRunning()); + }); + + afterEach(async () => { + await teemService.destroy(); + await coreStub.destroyServices(); + sinon.restore(); + }); + + it('should send TEEM report for minimal declaration', () => processDeclaration({ + class: 'Telemetry', + schemaVersion: '1.36.0' + }) + .then(() => { + assert.lengthOf(teemRecords, 1); + assert.deepStrictEqual( + teemRecords[0].info, + { + name: 'Telemetry Streaming', + version: appInfo.version + } + ); + assert.deepStrictEqual( + teemRecords[0].record, + { + regkey: 'unknown', + platformID: 'unknown', + platform: 'unknown', + platformVersion: 'unknown', + nicConfiguration: 'unknown', + modules: {}, + Telemetry: 1, + consumers: {}, + inlineIHealthPollers: 0, + inlineSystemPollers: 0 + } + ); + })); + + it('should send multiple TEEM reports', () => processDeclaration({ + class: 'Telemetry', + schemaVersion: '1.36.0' + }) + .then(() => { + assert.lengthOf(teemRecords, 1); + assert.deepStrictEqual( + teemRecords[0].info, + { + name: 'Telemetry Streaming', + version: appInfo.version + } + ); + assert.deepStrictEqual( + teemRecords[0].record, + { + regkey: 'unknown', + platformID: 'unknown', + platform: 'unknown', + platformVersion: 'unknown', + nicConfiguration: 'unknown', + modules: {}, + Telemetry: 1, + consumers: {}, + inlineIHealthPollers: 0, + inlineSystemPollers: 0 + } + ); + + return processDeclaration({ + class: 'Telemetry', + schemaVersion: '1.36.0', + listener: { + class: 'Telemetry_Listener' + } + }); + }) + .then(() => { + assert.lengthOf(teemRecords, 2); + assert.deepStrictEqual( + teemRecords[1].info, + { + name: 'Telemetry Streaming', + version: appInfo.version + } + ); + assert.deepStrictEqual( + teemRecords[1].record, + { + regkey: 'unknown', + platformID: 'unknown', + platform: 'unknown', + platformVersion: 'unknown', + nicConfiguration: 'unknown', + modules: {}, + Telemetry: 1, + Telemetry_Listener: 1, + consumers: {}, + inlineIHealthPollers: 0, + inlineSystemPollers: 0 + } + ); + })); + + it('should send TEEM report', () => processDeclaration({ + class: 'Telemetry', + schemaVersion: '1.11.0', + consumer1: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: 'x.x.x.x' + }, + consumer2: { + class: 'Telemetry_Consumer', + type: 'Splunk', + host: 'x.x.x.x', + passphrase: { cipherText: 'passphrase' } + }, + consumer3: { + class: 'Telemetry_Consumer', + type: 'Azure_Log_Analytics', + workspaceId: 'workspaceId', + passphrase: { cipherText: 'passphrase' } + }, + consumer4: { + class: 'Telemetry_Consumer', + type: 'Graphite', + host: 'x.x.x.x' + }, + consumer5: { + class: 'Telemetry_Consumer', + type: 'Kafka', + host: 'x.x.x.x', + topic: 'topic' + }, + consumer6: { + class: 'Telemetry_Consumer', + type: 'ElasticSearch', + host: 'x.x.x.x', + index: 'index' + }, + consumer7: { + class: 'Telemetry_Consumer', + type: 'Generic_HTTP', + host: 'x.x.x.x' + }, + consumer8: { + class: 'Telemetry_Consumer', + type: 'Azure_Log_Analytics', + workspaceId: 'workspaceId', + passphrase: { cipherText: 'passphrase' } + }, + consumer9: { + class: 'Telemetry_Consumer', + type: 'Azure_Log_Analytics', + workspaceId: 'workspaceId', + passphrase: { cipherText: 'passphrase' } + }, + system1: { + class: 'Telemetry_System', + systemPoller: [ + { interval: 300 }, + { interval: 300 }, + 'systemPoller1' + ], + iHealthPoller: { + username: 'username', + passphrase: { + cipherText: 'passphrase' + }, + interval: { + timeWindow: { + start: '23:15', + end: '02:15' + }, + frequency: 'daily' + } + } + }, + systemPoller1: { + class: 'Telemetry_System_Poller' + }, + system2: { + class: 'Telemetry_System', + systemPoller: { + interval: 100 + } + } + }) + .then(() => { + assert.lengthOf(teemRecords, 1); + assert.deepStrictEqual( + teemRecords[0].info, + { + name: 'Telemetry Streaming', + version: appInfo.version + } + ); + assert.deepStrictEqual( + teemRecords[0].record, + { + regkey: 'unknown', + platformID: 'unknown', + platform: 'unknown', + platformVersion: 'unknown', + nicConfiguration: 'unknown', + modules: {}, + Telemetry: 1, + Telemetry_Consumer: 9, + Secret: 5, + Telemetry_System: 2, + Telemetry_System_Poller: 1, + consumers: { + Azure_Log_Analytics: 3, + ElasticSearch: 1, + Generic_HTTP: 2, + Graphite: 1, + Kafka: 1, + Splunk: 1 + }, + inlineIHealthPollers: 1, + inlineSystemPollers: 3 + } + ); + })); + + it('should not thorw error if reporting failed', () => { + teemReportStub.throws(new Error('expected error')); + return processDeclaration({ + class: 'Telemetry', + schemaVersion: '1.36.0' + }) + .then(() => { + assert.lengthOf(teemRecords, 0); + assert.includeMatch( + coreStub.logger.messages.debug, + /Unable to send analytics data/ + ); + }); + }); +}); diff --git a/test/unit/teemReporterTests.js b/test/unit/teemReporterTests.js deleted file mode 100644 index 11998be2..00000000 --- a/test/unit/teemReporterTests.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('./shared/restoreCache')(); - -const sinon = require('sinon'); -const TeemRecord = require('@f5devcentral/f5-teem').Record; - -const assert = require('./shared/assert'); -const sourceCode = require('./shared/sourceCode'); -const stubs = require('./shared/stubs'); - -const appInfo = sourceCode('src/lib/appInfo'); -const configWorker = sourceCode('src/lib/config'); -const constants = sourceCode('src/lib/constants'); -const TeemReporter = sourceCode('src/lib/teemReporter').TeemReporter; - -moduleCache.remember(); - -describe('TeemReporter', () => { - before(() => { - moduleCache.restore(); - }); - - beforeEach(() => { - stubs.default.coreStub({ teemReporter: false }); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('constructor', () => { - it('should use application version and name from \'appInfo\' when generating asset info', () => { - const expectedAppName = 'expectedAppName'; - sinon.stub(constants, 'APP_NAME').value(expectedAppName); - - const teemReporter = new TeemReporter(); - assert.strictEqual(teemReporter.assetInfo.name, expectedAppName); - assert.strictEqual(teemReporter.assetInfo.version, appInfo.version); - }); - }); - - describe('.process', () => { - const decl = { - class: 'Telemetry', - schemaVersion: '1.6.0' - }; - it('should send TEEM report', () => { - let recordSent; - const teemReporter = new TeemReporter(); - const teemDevice = teemReporter.teemDevice; - const methods = [ - 'addClassCount', - 'addPlatformInfo', - 'addRegKey', - 'addProvisionedModules', - 'addJsonObject', - 'calculateAssetId' - ]; - const teemSpies = methods.map((m) => ({ name: m, instance: sinon.spy(TeemRecord.prototype, m) })); - - const reportRecordStub = sinon.stub(teemDevice, 'reportRecord'); - reportRecordStub.callsFake((record) => { - recordSent = record; - }); - - return teemReporter.process(decl) - .then(() => { - assert.isTrue(reportRecordStub.calledOnce, 'Expected method reportRecord() to be called once'); - teemSpies.forEach((spy) => { - assert.isTrue(spy.instance.calledOnce, `Expected method ${spy.name}() to be called once`); - }); - assert.deepStrictEqual(recordSent.recordBody.consumers, {}); - assert.strictEqual(recordSent.recordBody.Telemetry, 1); - }); - }); - - it('should not throw an error if reporting failed', () => { - const teemReporter = new TeemReporter(); - sinon.stub(teemReporter.teemDevice, 'reportRecord').rejects({ message: 'TEEM failed!' }); - const loggerSpy = sinon.spy(teemReporter.logger, 'debugException'); - return teemReporter.process(decl) - .then(() => { - assert.isTrue(loggerSpy.callCount >= 1, 'should call logger.debugException at least once'); - assert.deepStrictEqual(loggerSpy.firstCall.args, ['Unable to send analytics data', { message: 'TEEM failed!' }]); - }); - }); - }); - - describe('.fetchExtraData()', () => { - const teemReporter = new TeemReporter(); - const validate = (decl, expectedExtraData) => configWorker.processDeclaration(decl) - .then((validConfig) => { - const extraData = teemReporter.fetchExtraData(validConfig); - assert.deepStrictEqual(extraData, expectedExtraData); - }); - - it('should process empty object', () => { - const result = teemReporter.fetchExtraData({}); - assert.deepStrictEqual(result, { - consumers: {}, - inlineIHealthPollers: 0, - inlineSystemPollers: 0 - }); - }); - - it('should process empty declaration', () => { - const expectedExtraData = { - consumers: {}, - inlineIHealthPollers: 0, - inlineSystemPollers: 0 - }; - const declaration = { - class: 'Telemetry', - schemaVersion: '1.11.0' - }; - return validate(declaration, expectedExtraData); - }); - - it('should return object with counters calculated from declaration', () => { - const declaration = { - class: 'Telemetry', - schemaVersion: '1.11.0', - consumer1: { - class: 'Telemetry_Consumer', - type: 'Generic_HTTP', - host: 'x.x.x.x' - }, - consumer2: { - class: 'Telemetry_Consumer', - type: 'Splunk', - host: 'x.x.x.x', - passphrase: { cipherText: 'passphrase' } - }, - consumer3: { - class: 'Telemetry_Consumer', - type: 'Azure_Log_Analytics', - workspaceId: 'workspaceId', - passphrase: { cipherText: 'passphrase' } - }, - consumer4: { - class: 'Telemetry_Consumer', - type: 'Graphite', - host: 'x.x.x.x' - }, - consumer5: { - class: 'Telemetry_Consumer', - type: 'Kafka', - host: 'x.x.x.x', - topic: 'topic' - }, - consumer6: { - class: 'Telemetry_Consumer', - type: 'ElasticSearch', - host: 'x.x.x.x', - index: 'index' - }, - consumer7: { - class: 'Telemetry_Consumer', - type: 'Generic_HTTP', - host: 'x.x.x.x' - }, - consumer8: { - class: 'Telemetry_Consumer', - type: 'Azure_Log_Analytics', - workspaceId: 'workspaceId', - passphrase: { cipherText: 'passphrase' } - }, - consumer9: { - class: 'Telemetry_Consumer', - type: 'Azure_Log_Analytics', - workspaceId: 'workspaceId', - passphrase: { cipherText: 'passphrase' } - }, - system1: { - class: 'Telemetry_System', - systemPoller: [ - { interval: 300 }, - { interval: 300 }, - 'systemPoller1' - ], - iHealthPoller: { - username: 'username', - passphrase: { - cipherText: 'passphrase' - }, - interval: { - timeWindow: { - start: '23:15', - end: '02:15' - }, - frequency: 'daily' - } - } - }, - systemPoller1: { - class: 'Telemetry_System_Poller' - }, - system2: { - class: 'Telemetry_System', - systemPoller: { - interval: 100 - } - } - }; - const expectedExtraData = { - consumers: { - Generic_HTTP: 2, - Splunk: 1, - Azure_Log_Analytics: 3, - Graphite: 1, - Kafka: 1, - ElasticSearch: 1 - }, - inlineIHealthPollers: 1, - inlineSystemPollers: 3 - }; - return validate(declaration, expectedExtraData); - }); - }); -}); diff --git a/test/unit/tracerManagerTests.js b/test/unit/tracerManagerTests.js index db75ad99..e47ade63 100644 --- a/test/unit/tracerManagerTests.js +++ b/test/unit/tracerManagerTests.js @@ -26,7 +26,6 @@ const sourceCode = require('./shared/sourceCode'); const stubs = require('./shared/stubs'); const testUtil = require('./shared/util'); -const configWorker = sourceCode('src/lib/config'); const tracer = sourceCode('src/lib/utils/tracer'); const tracerMgr = sourceCode('src/lib/tracerManager'); @@ -35,6 +34,7 @@ moduleCache.remember(); describe('Tracer Manager', () => { const tracerFile = 'tracerTest'; const fakeDate = new Date(); + let configWorker; let coreStub; const addTimestamps = (data) => data.map((item) => ({ data: item, timestamp: new Date().toISOString() })); @@ -43,21 +43,26 @@ describe('Tracer Manager', () => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = stubs.default.coreStub(); - stubs.clock({ fakeTimersOpts: fakeDate }); + configWorker = coreStub.configWorker.configWorker; + + await coreStub.startServices(); + await configWorker.processDeclaration(dummies.declaration.base.decrypted()); + coreStub.logger.removeAllMessages(); - return configWorker.processDeclaration(dummies.declaration.base.decrypted()) - .then(() => { - assert.isEmpty(tracerMgr.registered(), 'should have no registered tracers'); - }); + stubs.clock({ fakeTimersOpts: fakeDate }); + + assert.isEmpty(tracerMgr.registered(), 'should have no registered tracers'); }); - afterEach(() => configWorker.processDeclaration(dummies.declaration.base.decrypted()) - .then(() => { - assert.isEmpty(tracerMgr.registered(), 'should have no registered tracers'); - sinon.restore(); - })); + afterEach(async () => { + await configWorker.processDeclaration(dummies.declaration.base.decrypted()); + await coreStub.destroyServices(); + + assert.isEmpty(tracerMgr.registered(), 'should have no registered tracers'); + sinon.restore(); + }); describe('.fromConfig()', () => { let tracerInst; diff --git a/test/unit/utils/assertTests.js b/test/unit/utils/assertTests.js new file mode 100644 index 00000000..4bf564ec --- /dev/null +++ b/test/unit/utils/assertTests.js @@ -0,0 +1,84 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order */ +const moduleCache = require('../shared/restoreCache')(); + +const assert = require('../shared/assert'); +const sourceCode = require('../shared/sourceCode'); + +const assertUtil = sourceCode('src/lib/utils/assert'); + +moduleCache.remember(); + +describe('Misc Util', () => { + before(() => { + moduleCache.restore(); + }); + + describe('assert', () => { + describe('.boolean()', () => { + it('should validate boolean', () => { + assert.throws(() => assertUtil.boolean(null, 'boolean')); + assert.throws(() => assertUtil.boolean({}, 'boolean')); + assert.throws(() => assertUtil.boolean('', 'boolean')); + assert.throws(() => assertUtil.boolean(undefined, 'boolean')); + + assertUtil.boolean(true, 'boolean'); + assertUtil.boolean(false, 'boolean'); + }); + }); + + describe('.object()', () => { + it('should validate object', () => { + assert.throws(() => assertUtil.object([], 'object')); + assert.throws(() => assertUtil.object([10], 'object')); + assert.throws(() => assertUtil.object(true, 'object')); + assert.throws(() => assertUtil.object(10, 'object')); + assert.throws(() => assertUtil.object(null, 'object')); + assert.throws(() => assertUtil.object(undefined, 'object')); + assert.throws(() => assertUtil.object({}, 'object')); + + assertUtil.object({ key: 'value' }, 'object'); + }); + }); + + describe('.instanceOf()', () => { + it('should validate instance', () => { + class Class {} + class AnotherClass {} + const inst = new Class(); + + assert.throws(() => assertUtil.instanceOf(inst, AnotherClass, 'instance')); + + assertUtil.instanceOf(inst, Class, 'instance'); + }); + }); + + describe('.string()', () => { + it('should validate string', () => { + assert.throws(() => assertUtil.string(null, 'string')); + assert.throws(() => assertUtil.string({}, 'string')); + assert.throws(() => assertUtil.string('', 'string')); + assert.throws(() => assertUtil.string(undefined, 'string')); + + assertUtil.string('string', 'string'); + }); + }); + }); +}); diff --git a/test/unit/utils/configTests.js b/test/unit/utils/configTests.js index 72d75a53..58f57132 100644 --- a/test/unit/utils/configTests.js +++ b/test/unit/utils/configTests.js @@ -27,29 +27,53 @@ const sourceCode = require('../shared/sourceCode'); const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); +const srcAssert = sourceCode('src/lib/utils/assert'); const configUtil = sourceCode('src/lib/utils/config'); -const configWorker = sourceCode('src/lib/config'); +const constants = sourceCode('src/lib/constants'); moduleCache.remember(); describe('Config Util', () => { + let configWorker; let coreStub; const parseDeclaration = (declaration, options) => configWorker.processDeclaration( testUtil.deepCopy(declaration), options - ); + ) + .then((ret) => { + const components = configWorker.currentConfig.components; + assert.isDefined(components); + + components.forEach((comp) => { + if (comp.class === constants.CONFIG_CLASSES.IHEALTH_POLLER_CLASS_NAME) { + srcAssert.config.ihealthPoller(comp, 'iHealth Poller Component'); + } + if (comp.class === constants.CONFIG_CLASSES.SYSTEM_POLLER_CLASS_NAME) { + srcAssert.config.systemPoller(comp, 'System Poller Component'); + } + if (comp.class === constants.CONFIG_CLASSES.PULL_CONSUMER_SYSTEM_POLLER_GROUP_CLASS_NAME) { + srcAssert.config.pullConsumerPollerGroup(comp, 'Pull Consumer System Poller Group Component'); + } + }); + return ret; + }); before(() => { moduleCache.restore(); }); - beforeEach(() => { + beforeEach(async () => { coreStub = stubs.default.coreStub(); coreStub.utilMisc.generateUuid.numbersOnly = false; + + await coreStub.startServices(); + + configWorker = coreStub.configWorker.configWorker; }); - afterEach(() => { + afterEach(async () => { + await coreStub.destroyServices(); sinon.restore(); }); @@ -57,18 +81,30 @@ describe('Config Util', () => { it('should return empty array when no config passed', () => assert.isEmpty(configUtil.getComponents())); it('should return empty array when no .components', () => assert.isEmpty(configUtil.getComponents({}))); - configUtilTestData.getComponents.tests.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => parseDeclaration(testConf.declaration) - .then(() => { - assert.sameDeepMembers( - configUtil.getComponents(configWorker.currentConfig, { - class: testConf.classFilter, - filter: testConf.filter, - namespace: testConf.namespaceFilter - }), - testConf.expected - ); - })); + it('should pass combinations tests', async () => { + await parseDeclaration(configUtilTestData.getComponents.declaration); + + testUtil.product( + configUtilTestData.getComponents.params.class, + configUtilTestData.getComponents.params.filter, + configUtilTestData.getComponents.params.name, + configUtilTestData.getComponents.params.namespace + ).forEach(([cls, filter, name, namespace]) => { + const expected = cls.expected + .filter((c) => filter.expected.find((e) => e.id === c.id) + && name.expected.find((e) => e.id === c.id) + && namespace.expected.find((e) => e.id === c.id)); + + assert.sameDeepMembers( + configUtil.getComponents(configWorker.currentConfig, { + class: cls.filter, + filter: filter.filter, + name: name.filter, + namespace: namespace.filter + }).map((c) => ({ id: c.id, class: c.class })), + expected + ); + }); }); }); @@ -132,150 +168,65 @@ describe('Config Util', () => { class: 'Telemetry_Pull_Consumer', type: 'default', systemPoller: ['poller'] + }, + pullConsumer2: { + class: 'Telemetry_Pull_Consumer', + type: 'default', + enable: false, + systemPoller: ['poller'] } } }; return parseDeclaration(rawDecl) .then(() => { - let listener = configUtil.getTelemetryListeners(configWorker.currentConfig, 'f5telemetry_default')[0]; + let listener = configUtil.getTelemetryListeners(configWorker.currentConfig, { namespace: 'f5telemetry_default' })[0]; assert.isEmpty(configUtil.getReceivers(configWorker.currentConfig, listener)); - listener = configUtil.getTelemetryListeners(configWorker.currentConfig, 'My_Namespace_1')[0]; + listener = configUtil.getTelemetryListeners(configWorker.currentConfig, { namespace: 'My_Namespace_1' })[0]; assert.deepStrictEqual( configUtil.getReceivers(configWorker.currentConfig, listener).map((c) => c.id), ['My_Namespace_1::consumer'] ); - listener = configUtil.getTelemetryListeners(configWorker.currentConfig, 'My_Namespace_2')[0]; + listener = configUtil.getTelemetryListeners(configWorker.currentConfig, { namespace: 'My_Namespace_2' })[0]; assert.deepStrictEqual( configUtil.getReceivers(configWorker.currentConfig, listener).map((c) => c.id), ['My_Namespace_2::consumer_1', 'My_Namespace_2::consumer_2'] ); - let poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig, 'My_Namespace_3')[0]; + let poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig, { namespace: 'My_Namespace_3' })[0]; assert.deepStrictEqual( configUtil.getReceivers(configWorker.currentConfig, poller).map((c) => c.id), ['My_Namespace_3::consumer'] ); - poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig, 'My_Namespace_4')[0]; + poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig, { namespace: 'My_Namespace_4' })[0]; assert.isEmpty(configUtil.getReceivers(configWorker.currentConfig, poller)); - let pullConsumerGroup = configUtil.getTelemetryPullConsumerSystemPollerGroups(configWorker.currentConfig, 'My_Namespace_3')[0]; - assert.deepStrictEqual( - configUtil.getReceivers(configWorker.currentConfig, pullConsumerGroup).map((c) => c.id), - ['My_Namespace_3::pullConsumer'] + let pullConsumer = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { namespace: 'My_Namespace_3' })[0]; + let pullConsumerGroup = configUtil.getTelemetryPullConsumerSystemPollerGroup( + configWorker.currentConfig, pullConsumer ); - pullConsumerGroup = configUtil.getTelemetryPullConsumerSystemPollerGroups(configWorker.currentConfig, 'My_Namespace_4')[0]; assert.deepStrictEqual( configUtil.getReceivers(configWorker.currentConfig, pullConsumerGroup).map((c) => c.id), - ['My_Namespace_4::pullConsumer'] - ); - }); - }); - }); - - describe('.getSources()', () => { - it('should return data sources when defined', () => { - const rawDecl = { - class: 'Telemetry', - consumer: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace_1: { - class: 'Telemetry_Namespace', - listener: { - class: 'Telemetry_Listener' - }, - consumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }, - My_Namespace_2: { - class: 'Telemetry_Namespace', - listener_1: { - class: 'Telemetry_Listener' - }, - listener_2: { - class: 'Telemetry_Listener' - }, - consumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - }, - My_Namespace_3: { - class: 'Telemetry_Namespace', - poller: { - class: 'Telemetry_System_Poller' - }, - consumer: { - class: 'Telemetry_Consumer', - type: 'default' - }, - pullConsumer: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: ['poller'] - } - }, - My_Namespace_4: { - class: 'Telemetry_Namespace', - poller: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - consumer: { - class: 'Telemetry_Consumer', - type: 'default' - }, - pullConsumer: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: ['poller'] - } - } - }; - return parseDeclaration(rawDecl) - .then(() => { - let consumer = configUtil.getTelemetryConsumers(configWorker.currentConfig, 'f5telemetry_default')[0]; - assert.isEmpty(configUtil.getSources(configWorker.currentConfig, consumer)); - - consumer = configUtil.getTelemetryConsumers(configWorker.currentConfig, 'My_Namespace_1')[0]; - assert.deepStrictEqual( - configUtil.getSources(configWorker.currentConfig, consumer).map((c) => c.id), - ['My_Namespace_1::listener'] - ); - - consumer = configUtil.getTelemetryConsumers(configWorker.currentConfig, 'My_Namespace_2')[0]; - assert.deepStrictEqual( - configUtil.getSources(configWorker.currentConfig, consumer).map((c) => c.id), - ['My_Namespace_2::listener_1', 'My_Namespace_2::listener_2'] + ['My_Namespace_3::pullConsumer'] ); - consumer = configUtil.getTelemetryConsumers(configWorker.currentConfig, 'My_Namespace_3')[0]; - assert.deepStrictEqual( - configUtil.getSources(configWorker.currentConfig, consumer).map((c) => c.id), - ['My_Namespace_3::poller::poller'] + pullConsumer = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { namespace: 'My_Namespace_4', name: 'pullConsumer' })[0]; + pullConsumerGroup = configUtil.getTelemetryPullConsumerSystemPollerGroup( + configWorker.currentConfig, pullConsumer ); - let pullConsumer = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace_3')[0]; assert.deepStrictEqual( - configUtil.getSources(configWorker.currentConfig, pullConsumer).map((c) => c.id), - ['My_Namespace_3::Telemetry_Pull_Consumer_System_Poller_Group_pullConsumer'] + configUtil.getReceivers(configWorker.currentConfig, pullConsumerGroup).map((c) => c.id), + ['My_Namespace_4::pullConsumer'] ); - consumer = configUtil.getTelemetryConsumers(configWorker.currentConfig, 'My_Namespace_4')[0]; - assert.isEmpty(configUtil.getSources(configWorker.currentConfig, consumer)); - - pullConsumer = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace_4')[0]; - assert.deepStrictEqual( - configUtil.getSources(configWorker.currentConfig, pullConsumer).map((c) => c.id), - ['My_Namespace_4::Telemetry_Pull_Consumer_System_Poller_Group_pullConsumer'] - ); + pullConsumer = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { namespace: 'My_Namespace_4', name: 'pullConsumer2' })[0]; + assert.isUndefined(configUtil.getTelemetryPullConsumerSystemPollerGroup( + configWorker.currentConfig, pullConsumer + )); }); }); }); @@ -354,11 +305,7 @@ describe('Config Util', () => { testUtil.getCallableIt(testConf)(testConf.name, () => parseDeclaration(testConf.declaration) .then(() => { assert.strictEqual( - configUtil.hasEnabledComponents(configWorker.currentConfig, { - class: testConf.classFilter, - filter: testConf.filter, - namespace: testConf.namespaceFilter - }), + configUtil.hasEnabledComponents(configWorker.currentConfig), testConf.expected ); })); @@ -436,104 +383,6 @@ describe('Config Util', () => { }); }); - describe('.removeComponents()', () => { - it('should not fail no config passed', () => assert.doesNotThrow(() => configUtil.removeComponents())); - it('should not fail when no .components', () => assert.doesNotThrow(() => configUtil.removeComponents({}))); - - it('should remove all components', () => { - const decl = { - class: 'Telemetry', - My_Listener: { - class: 'Telemetry_Listener' - }, - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - My_Listener: { - class: 'Telemetry_Listener' - } - } - }; - return parseDeclaration(decl) - .then(() => { - const parsedConf = configWorker.currentConfig; - assert.deepStrictEqual(parsedConf.mappings, { 'f5telemetry_default::My_Listener': ['f5telemetry_default::My_Consumer'] }); - assert.lengthOf(parsedConf.components, 3); - - configUtil.removeComponents(parsedConf); - assert.deepStrictEqual(parsedConf.mappings, {}); - assert.isEmpty(parsedConf.components); - }); - }); - - it('should remove component and update mapping', () => { - const decl = { - class: 'Telemetry', - My_Listener: { - class: 'Telemetry_Listener' - }, - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Consumer_2: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Consumer_3: { - class: 'Telemetry_Consumer', - type: 'default' - }, - My_Namespace: { - class: 'Telemetry_Namespace', - My_Listener: { - class: 'Telemetry_Listener' - }, - My_Consumer: { - class: 'Telemetry_Consumer', - type: 'default' - } - } - }; - return parseDeclaration(decl) - .then(() => { - const parsedConf = configWorker.currentConfig; - assert.deepStrictEqual(parsedConf.mappings, { - 'f5telemetry_default::My_Listener': ['f5telemetry_default::My_Consumer', 'f5telemetry_default::My_Consumer_2', 'f5telemetry_default::My_Consumer_3'], - 'My_Namespace::My_Listener': ['My_Namespace::My_Consumer'] - }); - - configUtil.removeComponents(parsedConf, { filter: (c) => c.name === 'My_Consumer' }); - assert.deepStrictEqual(parsedConf.mappings, { - 'f5telemetry_default::My_Listener': ['f5telemetry_default::My_Consumer_2', 'f5telemetry_default::My_Consumer_3'] - }); - assert.lengthOf(configUtil.getTelemetryListeners(parsedConf), 2); - assert.lengthOf(configUtil.getTelemetryConsumers(parsedConf), 2); - - configUtil.removeComponents(parsedConf, { filter: (c) => c.name === 'My_Listener', namespace: 'f5telemetry_default' }); - assert.deepStrictEqual(parsedConf.mappings, {}); - assert.lengthOf(configUtil.getTelemetryListeners(parsedConf), 1); - assert.lengthOf(configUtil.getTelemetryConsumers(parsedConf), 2); - - configUtil.removeComponents(parsedConf, { class: 'Telemetry_Listener', namespace: (c) => c.namespace === 'My_Namespace' }); - assert.deepStrictEqual(parsedConf.mappings, {}); - assert.isEmpty(configUtil.getTelemetryListeners(parsedConf)); - assert.lengthOf(configUtil.getTelemetryConsumers(parsedConf), 2); - - configUtil.removeComponents(parsedConf, { filter: (c) => c.name === 'My_Consumer_2' }); - assert.deepStrictEqual(parsedConf.mappings, {}); - assert.lengthOf(configUtil.getTelemetryConsumers(parsedConf), 1); - - configUtil.removeComponents(parsedConf, { class: 'Telemetry_Consumer' }); - assert.deepStrictEqual(parsedConf.mappings, {}); - assert.isEmpty(configUtil.getTelemetryConsumers(parsedConf)); - }); - }); - }); - describe('.decryptAllSecrets()', () => { it('should decrypt secrets (JSON declaration)', () => { const encrypted = { @@ -622,187 +471,6 @@ describe('Config Util', () => { }); }); - describe('.getTelemetryPullConsumerSystemPollerGroupForPullConsumer()', () => { - const declaration = { - class: 'Telemetry', - Pull_Poller_1: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - Pull_Poller_2: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - Pull_Poller_3: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - My_Pull_Consumer_1: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: [ - 'Pull_Poller_1', - 'Pull_Poller_2' - ] - }, - My_Pull_Consumer_2: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: [ - 'Pull_Poller_2', - 'Pull_Poller_3' - ] - }, - My_Namespace: { - class: 'Telemetry_Namespace', - Pull_Poller_1: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - Pull_Poller_2: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - Pull_Poller_3: { - class: 'Telemetry_System_Poller', - interval: 0 - }, - My_Pull_Consumer_1: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: [ - 'Pull_Poller_1', - 'Pull_Poller_2' - ] - }, - My_Pull_Consumer_2: { - class: 'Telemetry_Pull_Consumer', - type: 'default', - systemPoller: [ - 'Pull_Poller_2', - 'Pull_Poller_3' - ] - } - } - }; - - it('should return Telemetry_Pull_Consumer_System_Poller_Group for each consumer', () => parseDeclaration(declaration) - .then(() => { - const pullPoller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig, 'f5telemetry_default') - .find((pc) => pc.name === 'Pull_Poller_1'); - assert.isNotEmpty(pullPoller); - assert.isUndefined(configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - configWorker.currentConfig, - pullPoller - ), 'should return "undefined" when unable to find component'); - - const pullConsumer1 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'f5telemetry_default') - .find((pc) => pc.name === 'My_Pull_Consumer_1'); - assert.isNotEmpty(pullConsumer1); - assert.deepStrictEqual( - configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - configWorker.currentConfig, - pullConsumer1 - ), - { - class: 'Telemetry_Pull_Consumer_System_Poller_Group', - enable: true, - id: 'f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - name: 'Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - namespace: 'f5telemetry_default', - pullConsumer: 'f5telemetry_default::My_Pull_Consumer_1', - traceName: 'f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - trace: { - enable: false - }, - systemPollers: [ - 'f5telemetry_default::Pull_Poller_1::Pull_Poller_1', - 'f5telemetry_default::Pull_Poller_2::Pull_Poller_2' - ] - }, - 'should return Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1' - ); - const pullConsumer2 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'f5telemetry_default') - .find((pc) => pc.name === 'My_Pull_Consumer_2'); - assert.isNotEmpty(pullConsumer1); - assert.deepStrictEqual( - configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - configWorker.currentConfig, - pullConsumer2 - ), - { - class: 'Telemetry_Pull_Consumer_System_Poller_Group', - enable: true, - id: 'f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - name: 'Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - namespace: 'f5telemetry_default', - pullConsumer: 'f5telemetry_default::My_Pull_Consumer_2', - traceName: 'f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - trace: { - enable: false - }, - systemPollers: [ - 'f5telemetry_default::Pull_Poller_2::Pull_Poller_2', - 'f5telemetry_default::Pull_Poller_3::Pull_Poller_3' - ] - }, - 'should return Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2' - ); - const pullConsumer3 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace') - .find((pc) => pc.name === 'My_Pull_Consumer_1'); - assert.isNotEmpty(pullConsumer1); - assert.deepStrictEqual( - configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - configWorker.currentConfig, - pullConsumer3 - ), - { - class: 'Telemetry_Pull_Consumer_System_Poller_Group', - enable: true, - id: 'My_Namespace::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - name: 'Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - namespace: 'My_Namespace', - pullConsumer: 'My_Namespace::My_Pull_Consumer_1', - traceName: 'My_Namespace::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1', - trace: { - enable: false - }, - systemPollers: [ - 'My_Namespace::Pull_Poller_1::Pull_Poller_1', - 'My_Namespace::Pull_Poller_2::Pull_Poller_2' - ] - }, - 'should return Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1' - ); - const pullConsumer4 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace') - .find((pc) => pc.name === 'My_Pull_Consumer_2'); - assert.isNotEmpty(pullConsumer1); - assert.deepStrictEqual( - configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( - configWorker.currentConfig, - pullConsumer4 - ), - { - class: 'Telemetry_Pull_Consumer_System_Poller_Group', - enable: true, - id: 'My_Namespace::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - name: 'Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - namespace: 'My_Namespace', - pullConsumer: 'My_Namespace::My_Pull_Consumer_2', - traceName: 'My_Namespace::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2', - trace: { - enable: false - }, - systemPollers: [ - 'My_Namespace::Pull_Poller_2::Pull_Poller_2', - 'My_Namespace::Pull_Poller_3::Pull_Poller_3' - ] - }, - 'should return Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2' - ); - })); - }); - describe('.getTelemetrySystemPollersForGroup()', () => { const declaration = { class: 'Telemetry', @@ -956,11 +624,13 @@ describe('Config Util', () => { it('should return Telemetry_System_Poller for each group', () => parseDeclaration(declaration) .then(() => { - const pullConsumer1 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'f5telemetry_default') - .find((pc) => pc.name === 'My_Pull_Consumer_1'); - assert.isNotEmpty(pullConsumer1); + const pullConsumer1 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { + name: (name) => name === 'My_Pull_Consumer_1', + namespace: 'f5telemetry_default' + })[0]; + assert.isDefined(pullConsumer1); - const pollerGroup1 = configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( + const pollerGroup1 = configUtil.getTelemetryPullConsumerSystemPollerGroup( configWorker.currentConfig, pullConsumer1 ); @@ -976,11 +646,13 @@ describe('Config Util', () => { 'should return Telemetry_System_Poller objects for f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1 group' ); - const pullConsumer2 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'f5telemetry_default') - .find((pc) => pc.name === 'My_Pull_Consumer_2'); - assert.isNotEmpty(pullConsumer2); + const pullConsumer2 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { + name: 'My_Pull_Consumer_2', + namespace: 'f5telemetry_default' + })[0]; + assert.isDefined(pullConsumer2); - const pollerGroup2 = configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( + const pollerGroup2 = configUtil.getTelemetryPullConsumerSystemPollerGroup( configWorker.currentConfig, pullConsumer2 ); @@ -996,11 +668,13 @@ describe('Config Util', () => { 'should return Telemetry_System_Poller objects for f5telemetry_default::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_2 group' ); - const pullConsumer3 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace') - .find((pc) => pc.name === 'My_Pull_Consumer_1'); - assert.isNotEmpty(pullConsumer3); + const pullConsumer3 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { + name: 'My_Pull_Consumer_1', + namespace: 'My_Namespace' + })[0]; + assert.isDefined(pullConsumer3); - const pollerGroup3 = configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( + const pollerGroup3 = configUtil.getTelemetryPullConsumerSystemPollerGroup( configWorker.currentConfig, pullConsumer3 ); @@ -1016,11 +690,13 @@ describe('Config Util', () => { 'should return Telemetry_System_Poller objects for My_Namespace::Telemetry_Pull_Consumer_System_Poller_Group_My_Pull_Consumer_1 group' ); - const pullConsumer4 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, 'My_Namespace') - .find((pc) => pc.name === 'My_Pull_Consumer_2'); - assert.isNotEmpty(pullConsumer4); + const pullConsumer4 = configUtil.getTelemetryPullConsumers(configWorker.currentConfig, { + name: 'My_Pull_Consumer_2', + namespace: 'My_Namespace' + })[0]; + assert.isDefined(pullConsumer4); - const pollerGroup4 = configUtil.getTelemetryPullConsumerSystemPollerGroupForPullConsumer( + const pollerGroup4 = configUtil.getTelemetryPullConsumerSystemPollerGroup( configWorker.currentConfig, pullConsumer4 ); @@ -1037,4 +713,300 @@ describe('Config Util', () => { ); })); }); + + describe('.getComponentHash()', () => { + const ignoredTopLevelKeys = ['enable', 'id', 'trace']; + + function verifyChanges(originObject) { + const originHash = configUtil.getComponentHash(originObject); + const objectCopy = testUtil.deepCopy(originObject); + const prevHash = []; + + function checkHash(path) { + const newHash = configUtil.getComponentHash(objectCopy); + assert.notDeepEqual(originHash, newHash, `should generate different from origin hash (${path.join('::')})`); + assert.notInclude(prevHash, newHash, `should not generate existing hash (${path.join('::')})`); + prevHash.push(newHash); + } + + function verify(data, level = 0, path = []) { + path = path.slice(); + path.push(''); + + if (Array.isArray(data)) { + data.forEach((item, idx) => { + path[path.length - 1] = idx; + + data[idx] = 'test_value'; + checkHash(path); + data[idx] = item; + + verify(item, level + 1, path.slice()); + }); + } else if (typeof data === 'object' && data !== null) { + if (level === 0) { + ignoredTopLevelKeys.forEach((key) => { + data[key] = 'test_value'; + assert.deepStrictEqual( + originHash, + configUtil.getComponentHash(objectCopy), + `should ignore new value for a top-level key "${key}"` + ); + }); + ignoredTopLevelKeys.forEach((key) => { + delete data[key]; + assert.deepStrictEqual( + originHash, + configUtil.getComponentHash(data), + `should ignore when top-level key "${key}" deleted` + ); + }); + } + // top level keys deleted at that moment already + Object.entries(data) + .forEach(([key, item]) => { + path[path.length - 1] = key; + + data[key] = 'test_value'; + checkHash(path); + data[key] = item; + + verify(item, level + 1, path.slice()); + }); + } + } + verify(objectCopy); + } + + it('should generate hash for Telemetry_iHealth_Poller', async () => { + await parseDeclaration({ + class: 'Telemetry', + My_System: { + class: 'Telemetry_System', + host: 'host1', + enable: true, + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + iHealthPoller: { + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + interval: { + timeWindow: { + start: '23:15', + end: '02:15' + } + }, + proxy: { + host: 'proxyhost', + port: 5555, + protocol: 'https' + } + }, + systemPoller: { + interval: 60, + workers: 6, + chunkSize: 60 + } + }, + My_System_2: { + class: 'Telemetry_System', + host: 'host1', + enable: true, + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + iHealthPoller: { + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + interval: { + timeWindow: { + start: '23:15', + end: '02:15' + } + }, + proxy: { + host: 'proxyhost', + port: 5555, + protocol: 'https' + } + }, + systemPoller: { + interval: 60, + workers: 6, + chunkSize: 60 + } + } + }); + + const poller = configUtil.getTelemetryIHealthPollers(configWorker.currentConfig)[0]; + assert.isDefined(poller); + + const originHash = configUtil.getComponentHash(poller); + + assert.isString(originHash, 'should be a string'); + assert.isNotEmpty(originHash, 'should be a non-empty string'); + + assert.deepStrictEqual( + originHash, + configUtil.getComponentHash(configUtil.getTelemetryIHealthPollers(configWorker.currentConfig)[0]), + 'should generate the same hash value' + ); + + const pollers = configUtil.getTelemetryIHealthPollers(configWorker.currentConfig); + + assert.notDeepEqual(pollers[0].id, pollers[1].id); + assert.notDeepEqual( + configUtil.getComponentHash(pollers[0]), + configUtil.getComponentHash(pollers[1]), + 'should generate different hash values for different objects' + ); + + pollers.forEach(verifyChanges); + }); + + it('should generate hash for Telemetry_System_Poller', async () => { + await parseDeclaration({ + class: 'Telemetry', + Poller_1: { + class: 'Telemetry_System_Poller', + enable: true, + host: 'host1', + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + tag: { + test: 'tag' + }, + endpointList: { + enable: true, + items: { + test: { + path: '/test' + } + } + } + }, + Poller_2: { + class: 'Telemetry_System_Poller', + enable: true, + host: 'host1', + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + tag: { + test: 'tag' + } + } + }); + + const poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig)[0]; + assert.isDefined(poller); + + const originHash = configUtil.getComponentHash(poller); + + assert.isString(originHash, 'should be a string'); + assert.isNotEmpty(originHash, 'should be a non-empty string'); + + assert.deepStrictEqual( + originHash, + configUtil.getComponentHash(configUtil.getTelemetrySystemPollers(configWorker.currentConfig)[0]), + 'should generate the same hash value' + ); + + const pollers = configUtil.getTelemetrySystemPollers(configWorker.currentConfig); + + assert.notDeepEqual(pollers[0].id, pollers[1].id); + assert.notDeepEqual( + configUtil.getComponentHash(pollers[0]), + configUtil.getComponentHash(pollers[1]), + 'should generate different hash values for different objects' + ); + + pollers.forEach(verifyChanges); + }); + + it('should generate hash for Telemetry_System', async () => { + await parseDeclaration({ + class: 'Telemetry', + Poller_1: { + class: 'Telemetry_System_Poller', + enable: true, + host: 'host1', + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + tag: { + test: 'tag' + }, + endpointList: { + enable: true, + items: { + test: { + path: '/test' + } + } + } + }, + System_1: { + class: 'Telemetry_System', + enable: true, + host: 'host1', + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + systemPoller: ['Poller_1'] + }, + System_2: { + class: 'Telemetry_System', + enable: true, + host: 'host1', + username: 'test_user_1', + passphrase: { + cipherText: 'test_passphrase_1' + }, + systemPoller: { + tag: { + test: 'tag' + } + } + } + }); + + const poller = configUtil.getTelemetrySystemPollers(configWorker.currentConfig)[0]; + assert.isDefined(poller); + + const originHash = configUtil.getComponentHash(poller); + + assert.isString(originHash, 'should be a string'); + assert.isNotEmpty(originHash, 'should be a non-empty string'); + + assert.deepStrictEqual( + originHash, + configUtil.getComponentHash(configUtil.getTelemetrySystemPollers(configWorker.currentConfig)[0]), + 'should generate the same hash value' + ); + + const pollers = configUtil.getTelemetrySystemPollers(configWorker.currentConfig); + + assert.notDeepEqual(pollers[0].id, pollers[1].id); + assert.notDeepEqual( + configUtil.getComponentHash(pollers[0]), + configUtil.getComponentHash(pollers[1]), + 'should generate different hash values for different objects' + ); + + pollers.forEach(verifyChanges); + }); + }); }); diff --git a/test/unit/utils/dacliTests.js b/test/unit/utils/dacliTests.js new file mode 100644 index 00000000..fb718cd4 --- /dev/null +++ b/test/unit/utils/dacliTests.js @@ -0,0 +1,556 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-restricted-syntax */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); + +const assert = require('../shared/assert'); +const bigipConnTests = require('../shared/tests/bigipConn'); +const bigipCredsTest = require('../shared/tests/bigipCreds'); +const BigIpApiMock = require('../shared/bigipAPIMock'); +const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); + +const dacli = sourceCode('src/lib/utils/dacli'); + +moduleCache.remember(); + +describe('Device Async CLI', () => { + before(() => { + moduleCache.restore(); + }); + + afterEach(() => { + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); + sinon.restore(); + }); + + describe('constructor', () => { + it('invalid arguments', async () => { + let errMsg = /cmd should be a (string|non-empty)/; + await assert.isRejected(dacli(), errMsg); + await assert.isRejected(dacli(undefined), errMsg); + await assert.isRejected(dacli(''), errMsg); + + const cmd = 'echo'; + errMsg = /retryDelay should be a safe number/; + await assert.isRejected(dacli(cmd, false), errMsg); + await assert.isRejected(dacli(cmd, Infinity), errMsg); + await assert.isRejected(dacli(cmd, Number.MAX_VALUE), errMsg); + + errMsg = /folder requires a partition to be defined/; + await assert.isRejected(dacli(cmd, { folder: 'test' }), errMsg); + + const hosts = [ + 'localhost', + 'remotehost' + ]; + for (const host of hosts) { + const credsTests = bigipCredsTest(host); + for (const testData of credsTests) { + await assert.isRejected(dacli(cmd, host, { credentials: testData.value }), testData.error); + } + + const connTests = bigipConnTests(); + for (const testData of connTests) { + await assert.isRejected(dacli(cmd, host, { + connection: testData.value, + credentials: { token: 'token' } + }), testData.error); + } + } + }); + }); + + const defaultUser = 'admin'; + const localhost = 'localhost'; + const remotehost = 'remotehost'; + + const combinations = testUtil.product( + // host config + [ + { + name: localhost, + value: localhost + }, + { + name: remotehost, + value: remotehost + } + ], + // credentials config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'admin with passphrase', + value: { username: defaultUser, passphrase: 'test_passphrase_1' } + }, + testUtil.smokeTests.ignore({ + name: 'non-default user', + value: { username: 'test_user_1', passphrase: 'test_passphrase_2' } + }), + testUtil.smokeTests.ignore({ + name: 'non-default passwordless user', + value: { username: 'test_user_1' } + }), + { + name: 'existing token', + value: { token: 'auto' } + } + ]), + // connection config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'non default', + value: { port: 8105, protocol: 'https' } + } + ]) + ); + + combinations.forEach(([hostConf, credentialsConf, connectionConf]) => { + if (hostConf.value === remotehost && credentialsConf.value && !credentialsConf.value.passphrase) { + // password-less user does not work with remote host + return; + } + + describe(`host = ${hostConf.name}, user = ${credentialsConf.name}, connection = ${connectionConf.name}`, () => { + const command = 'echo test'; + let bigip; + let connection; + let credentials; + let dacliStub; + let host; + + function makeRequestOptions(options = {}) { + return Object.assign(options, { connection, credentials }); + } + + beforeEach(() => { + connection = connectionConf.value; + credentials = credentialsConf.value; + host = hostConf.value; + + bigip = new BigIpApiMock(host, { + port: (connection && connection.port) || undefined, + protocol: (connection && connection.protocol) || undefined + }); + + if (credentials && credentials.token) { + bigip.addAuthToken(credentials.token); + } else if (host === remotehost && credentials) { + assert.allOfAssertions( + () => assert.isDefined(credentials.username, 'username should be defined for remote host'), + () => assert.isDefined(credentials.passphrase, 'passphrase should be defined for remote host') + ); + bigip.mockAuth(credentials.username, credentials.passphrase); + } else if (host === localhost) { + bigip.addPasswordlessUser( + (credentials && credentials.username) + ? credentials.username + : defaultUser + ); + } + + dacliStub = bigip.mockDACLI(command, { optionally: false }); + dacliStub.updateScript.interceptor.optionally(true); + }); + + function checkAllStubs({ + folder = '', + outputFile = '/dev/null', + partition = '', + scriptCode = 'proc script::run {} {\n set cmd [lreplace $tmsh::argv 0 0]; eval "exec $cmd 2> stderrfile"\n}', + scriptName = 'telemetry_delete_me__async_cli_cmd_script_runner', + skipStubs = [] + } = {}) { + scriptCode = scriptCode.replace('stderrfile', outputFile); + + const fullScriptName = [partition, folder, scriptName].filter((s) => s.length); + if (fullScriptName.length > 1) { + fullScriptName.splice(0, 0, ''); + } + + const tmosName = fullScriptName.join('/'); + const uriName = fullScriptName.join('~'); + + let stubKey = 'createScript'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + assert.isDefined(dacliStub[stubKey].scripts[tmosName]); + assert.deepStrictEqual(dacliStub[stubKey].scripts[tmosName], { + name: tmosName, + apiAnonymous: scriptCode + }); + } + + stubKey = 'updateScript'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args, [ + uriName, { + name: tmosName, + apiAnonymous: scriptCode + } + ]); + } + + stubKey = 'createTask'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args[1], { + command: 'run', + name: tmosName, + utilCmdArgs: command + }); + } + + stubKey = 'executeTask'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + } + + stubKey = 'pollTaskResult'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args[1], ''); + } + + stubKey = 'removeTaskResult'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args[1], ''); + } + + stubKey = 'removeTask'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args[1], ''); + } + + stubKey = 'removeScript'; + if (!skipStubs.includes(stubKey)) { + assert.deepStrictEqual(dacliStub[stubKey].stub.callCount, 1); + const args = dacliStub[stubKey].stub.args[0]; + assert.deepStrictEqual(args, [uriName, '']); + } + } + + if (hostConf.value === remotehost && typeof credentialsConf.value === 'undefined') { + it('should fail when no username and passphrase were passed to the function', async () => { + dacliStub.disable(); + await assert.isRejected(dacli(command, host, makeRequestOptions()), /credentials should be an object/); + }); + return; + } + + const scriptCombinations = testUtil.product( + // script name config + testUtil.smokeTests.filter([ + { name: 'default', value: undefined }, + testUtil.smokeTests.ignore({ name: 'custom', value: 'myscript' }) + ]), + // output file config + testUtil.smokeTests.filter([ + { name: 'default', value: undefined }, + testUtil.smokeTests.ignore({ name: 'custom relative path', value: 'myoutput' }), + testUtil.smokeTests.ignore({ name: 'custom absolute path', value: '/a/b/myoutput' }) + ]), + // paritition config + testUtil.smokeTests.filter([ + { name: 'default', value: undefined }, + testUtil.smokeTests.ignore({ name: 'custom', value: 'Tenant' }), + testUtil.smokeTests.ignore({ name: 'Common', value: 'Common' }) + ]), + // folder config + testUtil.smokeTests.filter([ + { name: 'default', value: undefined }, + testUtil.smokeTests.ignore({ name: 'custom', value: 'SubFolder' }) + ]) + ); + + scriptCombinations.forEach(([scriptConf, outputConf, partitionConf, folderConf]) => { + if (!(scriptConf && outputConf && partitionConf && folderConf)) { + return; + } + if (typeof partitionConf.value === 'undefined' && typeof folderConf.value !== 'undefined') { + return; + } + + describe(`script config: script = ${scriptConf.name} output = ${outputConf.name} partition = ${partitionConf.name} folder = ${folderConf.name}`, () => { + it('should execute command (no script update)', async () => { + await dacli(command, host, makeRequestOptions({ + folder: folderConf.value, + outputFile: outputConf.value, + partition: partitionConf.value, + scriptName: scriptConf.value + })); + + checkAllStubs({ + folder: folderConf.value, + outputFile: outputConf.value, + partition: partitionConf.value, + scriptName: scriptConf.value, + skipStubs: ['updateScript'] + }); + }); + + it('should update existing script', async () => { + dacliStub.updateScript.interceptor.optionally(false); + dacliStub.createScript.stub.callsFake((script) => { + dacliStub.createScript.scripts[script.name] = script; + return [404, '']; + }); + + await dacli(command, host, makeRequestOptions({ + folder: folderConf.value, + outputFile: outputConf.value, + partition: partitionConf.value, + scriptName: scriptConf.value + })); + + checkAllStubs({ + folder: folderConf.value, + outputFile: outputConf.value, + partition: partitionConf.value, + scriptName: scriptConf.value + }); + }); + }); + }); + + testUtil.smokeTests.filter([ + testUtil.smokeTests.ignore('socketError'), + 'httpError' + ]).forEach((errorType) => { + if (!errorType) { + return; + } + it(`should not throw error when clenup methods failed (${errorType})`, async () => { + const stubs = [ + 'removeScript', + 'removeTask', + 'removeTaskResult' + ]; + + if (errorType === 'socketError') { + stubs.forEach((key) => { + dacliStub[key].remove(); + dacliStub[key].interceptor.replyWithError({ code: 500, message: errorType }); + }); + } else { + stubs.forEach((key) => { + dacliStub[key].stub.callsFake(() => [400, '']); + }); + } + + await dacli(command, host, makeRequestOptions()); + + if (errorType === 'socketError') { + checkAllStubs({ skipStubs: ['updateScript', ...stubs] }); + stubs.forEach((key) => { + assert.deepStrictEqual(dacliStub[key].stub.callCount, 0); + }); + } else { + checkAllStubs({ skipStubs: ['updateScript'] }); + } + }); + }); + + testUtil.smokeTests.filter([ + { name: 'status code only', value: [404, ''] }, + testUtil.smokeTests.ignore({ name: 'body code only', value: [200, { code: 404 }] }), + testUtil.smokeTests.ignore({ name: 'status code and body', value: [404, { anotherCode: 404 }] }), + testUtil.smokeTests.ignore({ name: 'status code and body code', value: [404, { code: 409 }] }) + ]).forEach((updateScriptConf) => { + if (!updateScriptConf) { + return; + } + + it(`should update existing script, ${updateScriptConf.name})`, async () => { + dacliStub.updateScript.interceptor.optionally(false); + dacliStub.createScript.stub.callsFake((script) => { + dacliStub.createScript.scripts[script.name] = script; + return updateScriptConf.value; + }); + + await dacli(command, host, makeRequestOptions()); + + checkAllStubs(); + }); + }); + + it('should re-try polling task status', async () => { + dacliStub.pollTaskResult.interceptor.times(2); + dacliStub.pollTaskResult.stub + .onFirstCall() + .callsFake(() => [200, { _taskState: 'IN_PROGRESS' }]); + + await dacli(command, 1, host, makeRequestOptions()); + + checkAllStubs({ skipStubs: ['updateScript', 'pollTaskResult'] }); + assert.deepStrictEqual(dacliStub.pollTaskResult.stub.callCount, 2); + }); + + it('should re-trying task results polling when invalid body received', async () => { + dacliStub.pollTaskResult.interceptor.times(3); + dacliStub.pollTaskResult.stub.onFirstCall().callsFake(() => [200, 'something']); + dacliStub.pollTaskResult.stub.onSecondCall().callsFake(() => [200, {}]); + + await dacli(command, 1, host, makeRequestOptions()); + + assert.deepStrictEqual(dacliStub.pollTaskResult.stub.callCount, 3); + }); + + // order matters - execution order + const execSteps = [ + 'createScript', + 'updateScript', + 'createTask', + 'executeTask', + 'pollTaskResult' + ]; + testUtil.product( + execSteps, + testUtil.smokeTests.filter([ + testUtil.smokeTests.ignore('socketError'), + 'httpError' + ]) + ).forEach(([stepName, errorType]) => { + if (!(stepName && errorType)) { + return; + } + + it(`should fail on error "${errorType}" ("${stepName}" step)`, async () => { + let errorMsg; + const expectations = []; + const socketError = { code: 500, message: 'socket error' }; + + function replyWithError(stub) { + stub.remove(); + stub.interceptor.replyWithError(socketError); + } + + dacliStub.disable(); + for (let i = 0; i <= execSteps.indexOf(stepName); i += 1) { + dacliStub[execSteps[i]].interceptor.optionally(false); + } + + // NOTE: the only way to fail `createScript` is socket error + + if (errorType === 'socketError' || stepName === 'createScript') { + replyWithError(dacliStub[stepName]); + errorMsg = /DeviceAsyncCLI.execute: HTTP Error/; + } + + if (stepName === 'updateScript') { + // need `createScript` stub to record request and return error to enable `updateScript` + dacliStub.createScript.stub.callsFake((script) => { + dacliStub.createScript.scripts[script.name] = script; + return [404, '']; + }); + } + + if (errorType === 'httpError') { + if (stepName === 'updateScript') { + dacliStub[stepName].stub.callsFake(() => [404, '']); + errorMsg = /DeviceAsyncCLI.execute: Failed to update the CLI script on device/; + } + + if (stepName === 'createTask') { + dacliStub[stepName].stub.callsFake(() => [200, {}]); + errorMsg = /DeviceAsyncCLI.execute: Failed to create a new task on the device/; + } + + if (stepName === 'executeTask') { + dacliStub[stepName].stub.callsFake(() => [200, '']); + errorMsg = /DeviceAsyncCLI.execute: Failed to execute the task on the device/; + } + + if (stepName === 'pollTaskResult') { + dacliStub[stepName].stub.callsFake(() => [200, { _taskState: 'FAILED' }]); + errorMsg = /DeviceAsyncCLI.execute: Task failed unexpectedly/; + } + } + + if (stepName !== 'updateScript') { + // step is optional due `createScript` success + dacliStub.updateScript.interceptor.optionally(true); + } + + if (execSteps.indexOf(stepName) >= execSteps.indexOf('updateScript')) { + dacliStub.removeScript.interceptor.optionally(false); + expectations.push(() => assert.deepStrictEqual( + dacliStub.removeScript.stub.callCount, 1, 'should call "removeScript" stub' + )); + } + + if (execSteps.indexOf(stepName) >= execSteps.indexOf('executeTask')) { + dacliStub.removeTask.interceptor.optionally(false); + expectations.push(() => assert.deepStrictEqual( + dacliStub.removeTask.stub.callCount, 1, 'should call "removeTask" stub' + )); + } + + if (execSteps.indexOf(stepName) >= execSteps.indexOf('pollTaskResult')) { + dacliStub.removeTaskResult.interceptor.optionally(false); + expectations.push(() => assert.deepStrictEqual( + dacliStub.removeTaskResult.stub.callCount, 1, 'should call "removeTaskResult" stub' + )); + } + + execSteps.forEach((sname, idx) => { + let expectedCallCount = execSteps.indexOf(stepName) >= idx ? 1 : 0; + if ( + stepName === 'createScript' + || (sname === 'updateScript' && stepName !== sname) + || (errorType === 'socketError' && stepName === sname) + ) { + // - `createScript` step expects stubs to be called 0 times + // - steps below `updateScript` do not expected to call `updateScript` stub + expectedCallCount = 0; + } + expectations.push(() => assert.deepStrictEqual( + dacliStub[sname].stub.callCount, + expectedCallCount, + `should call "${sname}" step ${expectedCallCount} time(s)` + )); + }); + + await assert.isRejected(dacli(command, 1, host, makeRequestOptions()), errorMsg); + + assert.allOfAssertions(...expectations); + }); + }); + }); + }); +}); diff --git a/test/unit/utils/dataTests.js b/test/unit/utils/dataTests.js index 330f6121..9e52ee05 100644 --- a/test/unit/utils/dataTests.js +++ b/test/unit/utils/dataTests.js @@ -41,7 +41,7 @@ describe('Data Util', () => { testConf.propertyCtx, testConf.propertyRegexCtx ); - assert.deepStrictEqual(resultCtx, testConf.expectedCtx); + assert.sameDeepMembers(resultCtx, testConf.expectedCtx); }); }); }); @@ -53,7 +53,7 @@ describe('Data Util', () => { testConf.data, testConf.propertiesCtx ); - assert.deepStrictEqual(resultCtx, testConf.expectedCtx); + assert.sameDeepMembers(resultCtx, testConf.expectedCtx); }); }); }); @@ -99,7 +99,7 @@ describe('Data Util', () => { testConf.propertiesCtx, callback ); - assert.deepStrictEqual(resultCtx, testConf.expectedCtx); + assert.sameDeepMembers(resultCtx, testConf.expectedCtx); }); }); }); diff --git a/test/unit/utils/deviceTests.js b/test/unit/utils/deviceTests.js index 88663ae5..def7c287 100644 --- a/test/unit/utils/deviceTests.js +++ b/test/unit/utils/deviceTests.js @@ -16,673 +16,1226 @@ 'use strict'; -/* eslint-disable import/order */ +/* eslint-disable import/order, no-bitwise, no-restricted-syntax */ const moduleCache = require('../shared/restoreCache')(); -const childProcess = require('child_process'); -const crypto = require('crypto'); +const nodeFS = require('fs'); const os = require('os'); -const fs = require('fs'); -const nock = require('nock'); -const request = require('request'); +const pathUtil = require('path'); const sinon = require('sinon'); -const urllib = require('url'); +const { Writable } = require('stream'); const assert = require('../shared/assert'); -const deviceUtilTestsData = require('../data/deviceUtilTestsData'); +const bigipConnTests = require('../shared/tests/bigipConn'); +const BigIpApiMock = require('../shared/bigipAPIMock'); const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); const constants = sourceCode('src/lib/constants'); const deviceUtil = sourceCode('src/lib/utils/device'); +const utilMisc = sourceCode('src/lib/utils/misc'); moduleCache.remember(); describe('Device Util', () => { + const defaultVersionData = { + build: '0.0.4', + date: 'Wed Aug 23 10:18:11 PDT 2023', + edition: 'Point Release 3', + product: 'BIG-IP', + title: 'Main Package', + version: ['17', '1', '0', '3'].join('.') + }; + const defaultUser = 'admin'; + const localhost = 'localhost'; + const remotehost = 'remotehost'; + before(() => { moduleCache.restore(); }); + beforeEach(() => { + stubs.default.coreStub({ + logger: true, + utilMisc: true + }); + }); + afterEach(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); }); - describe('Host Device Info', () => { - beforeEach(() => { - deviceUtil.clearHostDeviceInfo(); - }); + const combinations = testUtil.product( + // host config + [ + { + name: localhost, + value: localhost + }, + { + name: remotehost, + value: remotehost + } + ], + // credentials config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'non-default passwordless user', + value: { username: 'test_user_1' } + }, + { + name: 'existing token', + value: { token: 'auto' } + } + ]), + // connection config + testUtil.smokeTests.filter([ + { + name: 'default', + value: undefined + }, + { + name: 'non default', + value: { port: 8105, protocol: 'https', allowSelfSignedCert: true } + } + ]) + ); + + combinations.forEach(([hostConf, credentialsConf, connectionConf]) => { + if (hostConf.value === remotehost + && !(typeof credentialsConf.value !== 'undefined' + && (typeof credentialsConf.value.token !== 'undefined' + || typeof credentialsConf.value.passphrase !== 'undefined') + )) { + // remote host requires auth with username and passphrase or token + return; + } + + describe(`host = ${hostConf.name}, user = ${credentialsConf.name}, connection = ${connectionConf.name}`, () => { + let bigip; + let connection; + let credentials; + let host; + let requestSpies; + + function makeRequestOptions(options = {}) { + if (Object.keys(options).length === 0 && typeof connection === 'undefined' && typeof credentials === 'undefined') { + return undefined; + } + return Object.assign({}, connection, { credentials }, options); + } + + beforeEach(() => { + requestSpies = testUtil.requestSpies(); + + connection = connectionConf.value; + credentials = credentialsConf.value; + host = hostConf.value; + + bigip = new BigIpApiMock(host, { + port: (connection && connection.port) || undefined, + protocol: (connection && connection.protocol) || undefined + }); + + if (credentials && credentials.token) { + bigip.addAuthToken(credentials.token); + } else { + bigip.addPasswordlessUser( + (credentials && credentials.username) + ? credentials.username + : defaultUser + ); + } + + deviceUtil.clearHostDeviceInfo(); + }); + + afterEach(() => { + let strictSSL = true; + if (connectionConf.value && typeof connectionConf.value.allowSelfSignedCert === 'boolean') { + strictSSL = !connectionConf.value.allowSelfSignedCert; + } + testUtil.checkRequestSpies(requestSpies, { strictSSL }); + }); + + describe('.downloadFileFromDevice()', () => { + const bufferFiller = nodeFS.readFileSync(__filename); + const dstDir = os.tmpdir(); + const dstPath = pathUtil.join(dstDir, '/testDownloadFileUserStream'); + const downloadUri = '/uri/to/path'; + let downloadStub; + + class MyWritable extends Writable { + constructor(options) { + super(options); + this.chunks = []; + this.onWriteStub = sinon.stub(); + this.onWriteStub.callsFake((stream, chunk, cb) => cb(null)); + } + + _write(chunk, encoding, callback) { + this.onWriteStub(this, chunk, callback); + this.chunks.push(chunk); + } + + get buffer() { + return Buffer.concat(this.chunks); + } + } + + beforeEach(async () => { + await utilMisc.fs.mkdir(dstDir); + downloadStub = bigip.mockDownloadFileFromDevice(downloadUri); + }); + + const outputConfig = testUtil.smokeTests.filter([ + { name: 'output to a file', type: 'file', value: () => dstPath }, + testUtil.smokeTests.ignore({ name: 'output to a custom stream', type: 'customStream', value: () => new MyWritable() }), + { + name: 'output to a existing stream', + type: 'stream', + value: async () => { + const stream = utilMisc.fs.createWriteStream(dstPath); + // for stream to be opened + await (new Promise((resolve, reject) => { + try { + stream.write('test', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } catch (err) { + reject(err); + } + })); + return stream; + } + } + ]); + + outputConfig.forEach((testConf) => describe(testConf.name, () => { + function readOutput(output) { + if (testConf.type !== 'customStream') { + const data = utilMisc.fs.readFileSync(dstPath).toString(); + return testConf.type === 'stream' + ? data.slice(4) + : data; + } + // TODO: verify stream is closed + return output.buffer.toString(); + } + + const chunkSizes = testUtil.smokeTests.filter([ + 0.5, + 1, + 1.5, + 2, + testUtil.smokeTests.ignore(2.5), + testUtil.smokeTests.ignore(3) + ]); + + chunkSizes.forEach((chunkSize) => { + it(`should download file (file size = ${chunkSize} x chunk size)`, async () => { + const data = Buffer.allocUnsafe(constants.DEVICE_REST_API.CHUNK_SIZE * chunkSize); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => { + const chunk = data.slice(range.start, range.end + 1); + return [ + 200, + chunk, + Object.assign({}, range, { end: range.start + chunk.length - 1, size: data.length }) + ]; + }); + downloadStub.interceptor.times(Math.ceil(chunkSize)); + + const output = await testConf.value(); + await assert.becomes( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + data.length + ); + + assert.deepStrictEqual( + readOutput(output), data.toString() + ); + }); + + it(`should ignore range size after first response (file size = ${chunkSize} x chunk size)`, async () => { + const data = Buffer.allocUnsafe(constants.DEVICE_REST_API.CHUNK_SIZE * chunkSize); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => { + const chunk = data.slice(range.start, range.end + 1); + return [ + 200, + chunk, + Object.assign({}, range, { + end: range.start + chunk.length - 1, + size: downloadStub.stub.callCount === 1 ? data.length : 0 + }) + ]; + }); + downloadStub.interceptor.times(Math.ceil(chunkSize)); + + const output = await testConf.value(); + await assert.becomes( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + data.length + ); + + assert.deepStrictEqual( + readOutput(output), data.toString() + ); + }); + }); + + it('should close stream when got invalid HTTP status code', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => { + if (downloadStub.stub.callCount === 1) { + return [200, data, Object.assign({}, range, { + end: data.length - 1, + size: data.length * 6 + })]; + } + return [400, undefined, undefined, 'expected error msg']; + }); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: HTTP Error: 400/ + ); + }); + + it('should close stream when range size is zero', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [200, data, Object.assign({}, range, { size: 0 })]); + + const output = await testConf.value(); + await assert.becomes( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + 0 + ); + + assert.deepStrictEqual( + readOutput(output), '' + ); + }); + + it('should close stream when got no range header', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => { + if (downloadStub.stub.callCount === 1) { + return [200, data, Object.assign({}, range, { + end: data.length - 1, + size: data.length * 6 + })]; + } + return [200, undefined, undefined, 'abc']; + }); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: HTTP Error: 200.*invalid Content-Range header/ + ); + }); + + const crangeTests = [ + { + name: 'invalid range format', + error: /downloadFileFromDevice: HTTP Error: 200.*invalid Content-Range header/, + makeRange() { + return { + start: 'start', + end: 'end', + size: '*' + }; + } + }, + { + name: 'invalid range format (example 2)', + error: /downloadFileFromDevice: HTTP Error: 200.*invalid Content-Range header/, + makeRange() { + return { + start: '-1', + end: '-1', + size: '-1' + }; + } + }, + { + name: 'invalid range size (-1)', + error: /downloadFileFromDevice: HTTP Error: 200.*invalid Content-Range header/, + makeRange(data, range) { + return Object.assign({}, range, { + end: data.length - 1, + size: -1 + }); + } + }, + { + name: 'invalid range size (Number.MAX_SAFE_INTEGER + 1)', + error: /downloadFileFromDevice: totalSize should be a safe number/, + makeRange(data, range) { + return Object.assign({}, range, { + end: data.length - 1, + size: Number.MAX_SAFE_INTEGER + 1 + }); + } + }, + { + name: 'invalid range start (out of order)', + error: /downloadFileFromDevice: rangeStart should be ===/, + makeRange(data, range) { + return Object.assign({}, range, { + start: 10, + end: data.length - 1, + size: data.length + }); + } + }, + { + name: 'invalid range start (Number.MAX_SAFE_INTEGER + 1)', + error: /downloadFileFromDevice: rangeStart should be a safe number/, + makeRange(data, range) { + return Object.assign({}, range, { + start: Number.MAX_SAFE_INTEGER + 1, + end: data.length - 1, + size: data.length + }); + } + }, + { + name: 'invalid range end (Number.MAX_SAFE_INTEGER + 1)', + error: /downloadFileFromDevice: rangeEnd should be a safe number/, + makeRange(data, range) { + return Object.assign({}, range, { + end: Number.MAX_SAFE_INTEGER + 1, + size: data.length + }); + } + }, + { + name: 'invalid range size (Number.MAX_SAFE_INTEGER + 1)', + error: /downloadFileFromDevice: rangeSize should be a safe number/, + makeRange(data, range) { + return Object.assign({}, range, { + end: Number.MAX_SAFE_INTEGER, + size: data.length + }); + } + } + ]; + + crangeTests.forEach((crangeConf) => it(`should throw on ${crangeConf.name}`, async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + crangeConf.makeRange(data, range) + ]); + downloadStub.interceptor.times(1); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + crangeConf.error + ); + })); + + it('should throw on invalid range chunk size', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: downloadStub.stub.callCount === 1 ? 0 : data.length, + end: downloadStub.stub.callCount === 1 + ? (data.length - 1) + : 1, + size: data.length * 2 + }) + ]); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: rangeSize should be > 0/ + ); + }); - it('should gather device info', () => { - sinon.stub(deviceUtil, 'getDeviceType').resolves(constants.DEVICE_TYPE.BIG_IP); - sinon.stub(deviceUtil, 'getDeviceVersion').resolves({ version: '14.0.0' }); - return deviceUtil.gatherHostDeviceInfo() - .then(() => { + it('should throw on invalid payload size (!== rangeSize) (first request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + size: data.length * 2 + }) + ]); + downloadStub.interceptor.times(1); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: rangeSize should be ===/ + ); + }); + + it('should throw on invalid payload size (!== rangeSize) (second request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + downloadStub.stub.callCount === 1 ? data : data.slice(0, 25), + Object.assign({}, range, { + start: downloadStub.stub.callCount === 1 ? 0 : data.length, + end: downloadStub.stub.callCount === 1 + ? (data.length - 1) + : (data.length * 2 - 1), + size: data.length * 2 + }) + ]); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: rangeSize should be ===/ + ); + }); + + it('should throw on invalid range size (!== payload size) (first request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: 0, + end: data.length * 2 - 1, + size: data.length * 2 + }) + ]); + downloadStub.interceptor.times(1); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: rangeSize should be ===/ + ); + }); + + it('should throw on invalid range size (!== payload size) (second request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: downloadStub.stub.callCount === 1 ? 0 : data.length, + end: downloadStub.stub.callCount === 1 + ? (data.length - 1) + : (data.length * 3 - 1), + size: data.length * 3 + }) + ]); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: rangeSize should be ===/ + ); + }); + + it('should throw on invalid overall size (second request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: downloadStub.stub.callCount === 1 ? 0 : data.length, + end: downloadStub.stub.callCount === 1 + ? (data.length - 1) + : (data.length * 2 - 1), + size: data.length + 10 + }) + ]); + downloadStub.interceptor.times(2); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: the size of downloaded data/ + ); + }); + + it('should throw on invalid overall size (first request)', async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: 0, + end: data.length - 1, + size: data.length - 5 + }) + ]); + downloadStub.interceptor.times(1); + + const output = await testConf.value(); + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + /downloadFileFromDevice: the size of downloaded data/ + ); + }); + + if (testConf.type !== 'customStream') { + return; + } + + const streamTests = [ + { + name: 'stream "error" event', + error: /expected stream error event/, + makeError(stream, chunk, cb) { + stream.emit('error', new Error('expected stream error event')); + cb(); + }, + times: 1 + }, + { + name: 'write error (async)', + error: /expected stream async error/, + makeError(stream, chunk, cb) { + setImmediate(() => cb(new Error('expected stream async error'))); + } + }, + { + name: 'write error (sync)', + error: /expected stream sync error/, + makeError() { + throw new Error('expected stream sync error'); + } + } + ]; + testUtil.product(streamTests, [1, 2]) + .forEach(([streamErrorConf, requestsCount]) => it(`should throw on ${streamErrorConf.name} (${requestsCount} request(s))`, async () => { + const data = Buffer.allocUnsafe(50); + data.fill(bufferFiller); + + const times = (streamErrorConf.times || 0) + requestsCount; + + downloadStub.stub.callsFake((uri, range) => [ + 200, + data, + Object.assign({}, range, { + start: (downloadStub.stub.callCount - 1) * data.length, + end: data.length * downloadStub.stub.callCount - 1, + size: data.length * times + }) + ]); + downloadStub.interceptor.times(times); + + const output = await testConf.value(); + output.onWriteStub.onCall(requestsCount - 1).callsFake(streamErrorConf.makeError); + + await assert.isRejected( + deviceUtil.downloadFileFromDevice(output, host, downloadUri, makeRequestOptions()), + streamErrorConf.error + ); + })); + })); + }); + + describe('Host Device Info', () => { + if (hostConf.value !== localhost) { + return; + } + + let deviceTypeMock; + let deviceVersionMock; + + beforeEach(() => { + deviceTypeMock = bigip.mockDeviceType(); + deviceVersionMock = bigip.mockDeviceVersion(); + deviceUtil.clearHostDeviceInfo(); + }); + + it('should gather device info', async () => { + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); + + assert.deepStrictEqual(deviceTypeMock.callCount, 1); assert.deepStrictEqual( deviceUtil.getHostDeviceInfo(), { - TYPE: 'BIG-IP', - VERSION: { version: '14.0.0' }, - RETRIEVE_SECRETS_FROM_TMSH: false + TYPE: constants.DEVICE_TYPE.BIG_IP, + VERSION: defaultVersionData } ); }); - }); - it('should set and get info by key', () => { - deviceUtil.setHostDeviceInfo('key1', 'value1'); - deviceUtil.setHostDeviceInfo('key2', { value2: 10 }); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key1'), 'value1'); - assert.deepStrictEqual(deviceUtil.getHostDeviceInfo('key2'), { value2: 10 }); - }); + it('should override existing values', async () => { + deviceVersionMock.interceptor.times(2); + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); - it('should remove key', () => { - deviceUtil.setHostDeviceInfo('key1', 'value1'); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key1'), 'value1'); - deviceUtil.clearHostDeviceInfo('key1'); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key1'), undefined); - assert.deepStrictEqual(deviceUtil.getHostDeviceInfo(), {}); - }); + assert.deepStrictEqual(deviceTypeMock.callCount, 1); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo(), + { + TYPE: constants.DEVICE_TYPE.BIG_IP, + VERSION: defaultVersionData + } + ); - it('should remove keys', () => { - deviceUtil.setHostDeviceInfo('key1', 'value1'); - deviceUtil.setHostDeviceInfo('key2', 'value2'); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key1'), 'value1'); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key2'), 'value2'); - deviceUtil.clearHostDeviceInfo('key1', 'key2'); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key1'), undefined); - assert.strictEqual(deviceUtil.getHostDeviceInfo('key2'), undefined); - assert.deepStrictEqual(deviceUtil.getHostDeviceInfo(), {}); - }); - }); + deviceVersionMock.deviceVersionData.version = '16.0.0'; + deviceTypeMock.callsFake(() => Buffer.from('test')); - describe('.getDeviceType()', () => { - beforeEach(() => { - deviceUtil.clearHostDeviceInfo(); - }); + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); - it('should get container device type when /VERSION file is absent', () => { - sinon.stub(fs, 'readFile').callsFake((first, cb) => { - cb(new Error('foo'), null); - }); - return assert.becomes( - deviceUtil.getDeviceType(), - constants.DEVICE_TYPE.CONTAINER, - 'incorrect device type, should be CONTAINER' - ); - }); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo(), + { + TYPE: constants.DEVICE_TYPE.CONTAINER, + VERSION: Object.assign({}, defaultVersionData, { version: '16.0.0' }) + } + ); + assert.deepStrictEqual(deviceTypeMock.callCount, 2); + assert.deepStrictEqual(deviceVersionMock.stub.callCount, 2); + }); - it('should get container device type when /VERSION has no desired data', () => { - sinon.stub(fs, 'readFile').callsFake((first, cb) => { - cb(null, deviceUtilTestsData.getDeviceType.incorrectData); + it('should get info by key', async () => { + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('TYPE'), + constants.DEVICE_TYPE.BIG_IP + ); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('VERSION'), + defaultVersionData + ); + assert.isUndefined(deviceUtil.getHostDeviceInfo('non-existing-key')); + }); + + it('should remove key', async () => { + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('TYPE'), + constants.DEVICE_TYPE.BIG_IP + ); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('VERSION'), + defaultVersionData + ); + + deviceUtil.clearHostDeviceInfo('TYPE', 'VERSION'); + assert.isUndefined(deviceUtil.getHostDeviceInfo('TYPE')); + assert.isUndefined(deviceUtil.getHostDeviceInfo('VERSION')); + }); + + it('should remove keys', async () => { + await deviceUtil.gatherHostDeviceInfo(makeRequestOptions()); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('TYPE'), + constants.DEVICE_TYPE.BIG_IP + ); + assert.deepStrictEqual( + deviceUtil.getHostDeviceInfo('VERSION'), + defaultVersionData + ); + + deviceUtil.clearHostDeviceInfo(); + assert.isUndefined(deviceUtil.getHostDeviceInfo('TYPE')); + assert.isUndefined(deviceUtil.getHostDeviceInfo('VERSION')); + }); }); - return assert.becomes( - deviceUtil.getDeviceType(), - constants.DEVICE_TYPE.CONTAINER, - 'incorrect device type, should be CONTAINER' - ); - }); - it('should get BIG-IP device type', () => { - sinon.stub(fs, 'readFile').callsFake((first, cb) => { - cb(null, deviceUtilTestsData.getDeviceType.correctData); + describe('.encryptSecret()', () => { + if (hostConf.value !== localhost) { + return; + } + + function makeSecret(secret) { + return secret.match(/(.|\n){1,500}/g).map((s) => `$M$${Buffer.from(s).toString('base64')}`).join(','); + } + + it('should encrypt data that is 1k characters long', () => { + bigip.mockEncryptSecret({ replyTimes: 2 }); + const secret = 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc' + + 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca' + + 'bcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc' + + 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' + + 'cabcabcabcabcabcabcabcabca'; + + return assert.becomes( + deviceUtil.encryptSecret(secret, makeRequestOptions()), + makeSecret(secret) + ); + }); + + it('should chunk large secrets and preserve newlines when encrypting secrets', () => { + bigip.mockEncryptSecret({ replyTimes: 2 }); + // secret that is > 500 characters, with newlines + const largeSecret = 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' + + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n'; + + return assert.becomes( + deviceUtil.encryptSecret(largeSecret, makeRequestOptions()), + makeSecret(largeSecret) + ); + }); + + [ + 'httpError', + 'socketError' + ].forEach((errorType) => { + it(`should not fail when unable to delete radius object (${errorType})`, () => { + const encryptStubs = bigip.mockEncryptSecret({ replyTimes: 1 }); + + if (errorType === 'httpError') { + encryptStubs.delete.stub.callsFake(() => [404, null, 'error message']); + } else { + encryptStubs.delete.remove(); + encryptStubs.delete.interceptor.replyWithError({ code: 500, message: 'socket error' }); + } + + const secret = 'secret'; + return assert.becomes( + deviceUtil.encryptSecret(secret, makeRequestOptions()), + makeSecret(secret) + ); + }); + + it(`should fail when unable to encrypt secret ${errorType}`, () => { + let errMsg; + const encryptStubs = bigip.mockEncryptSecret({ replyTimes: 1 }); + + if (errorType === 'httpError') { + encryptStubs.encrypt.stub.callsFake(() => [404, null, 'error message']); + errMsg = /Bad status code: 404/; + } else { + encryptStubs.encrypt.remove(); + encryptStubs.encrypt.interceptor.replyWithError({ code: 500, message: 'socket error' }); + // `encrypt` stub doesn't recrod secret -> unable to match request + encryptStubs.delete.interceptor.optionally(true); + errMsg = /HTTP Error:/; + } + return assert.isRejected(deviceUtil.encryptSecret('foo', makeRequestOptions()), errMsg); + }); + }); + + it('should fail when encrypted secret has no `secret` property', () => { + const encryptStubs = bigip.mockEncryptSecret({ replyTimes: 1 }); + encryptStubs.encrypt.stub.callsFake(() => [200, { data: 'encrypted,secret' }]); + return assert.isRejected( + deviceUtil.encryptSecret('foo', makeRequestOptions()), + /Secret could not be retrieved/ + ); + }); + + it('should fail when encrypted secret has comma', () => { + const encryptStubs = bigip.mockEncryptSecret({ replyTimes: 1 }); + encryptStubs.encrypt.stub.callsFake(() => [200, { secret: 'encrypted,secret' }]); + return assert.isRejected( + deviceUtil.encryptSecret('foo', makeRequestOptions()), + /Encrypted data should not have a comma in it/ + ); + }); }); - return assert.becomes( - deviceUtil.getDeviceType(), - constants.DEVICE_TYPE.BIG_IP, - 'incorrect device type, should be BIG-IP' - ); - }); - it('should process /VERSION file correctly when readFile returns Buffer instead of String', () => { - sinon.stub(fs, 'readFile').callsFake((first, cb) => { - cb(null, Buffer.from(deviceUtilTestsData.getDeviceType.correctData)); + describe('.executeShellCommandOnDevice()', () => { + it('should execute shell command', () => { + const shellCmdStub = bigip.mockExecuteShellCommandOnDevice(/echo/); + shellCmdStub.stub.returns([200, 'test output']); + return assert.becomes( + deviceUtil.executeShellCommandOnDevice(host, 'echo something', makeRequestOptions()), + 'test output' + ); + }); + + it('should execute shell command (empty string in response)', () => { + const shellCmdStub = bigip.mockExecuteShellCommandOnDevice(/echo/); + shellCmdStub.stub.returns([200, '']); + return assert.becomes( + deviceUtil.executeShellCommandOnDevice(host, 'echo something', makeRequestOptions()), + '' + ); + }); }); - return assert.becomes( - deviceUtil.getDeviceType(), - constants.DEVICE_TYPE.BIG_IP, - 'incorrect device type, should be BIG-IP' - ); - }); - it('should read result from cache', () => { - const readFileStub = sinon.stub(fs, 'readFile'); - readFileStub.callsFake((first, cb) => { - cb(null, deviceUtilTestsData.getDeviceType.correctData); + describe('.getDeviceInfo()', () => { + it('should fetch device info', () => { + bigip.mockDeviceInfo({ + baseMac: '00:00:00:00:00:00', + build: '0.0.0', + chassisSerialNumber: '00000000-0000-0000-000000000000', + halUuid: '00000000-0000-0000-0000-000000000000', + hostMac: '00:00:00:00:00:00', + hostname: 'localhost.localdomain', + isClustered: false, + isVirtual: true, + machineId: '00000000-0000-0000-000000000000', + managementAddress: '192.168.1.10', + mcpDeviceName: '/Common/localhost.localdomain', + physicalMemory: 7168, + platform: 'Z100', + product: 'BIG-IP', + trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', + version: '15.1.0', + generation: 0, + lastUpdateMicros: 0, + kind: 'shared:resolver:device-groups:deviceinfostate', + selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' + }); + return assert.becomes( + deviceUtil.getDeviceInfo(host, makeRequestOptions()), + { + baseMac: '00:00:00:00:00:00', + build: '0.0.0', + chassisSerialNumber: '00000000-0000-0000-000000000000', + halUuid: '00000000-0000-0000-0000-000000000000', + hostMac: '00:00:00:00:00:00', + hostname: 'localhost.localdomain', + isClustered: false, + isVirtual: true, + machineId: '00000000-0000-0000-000000000000', + managementAddress: '192.168.1.10', + mcpDeviceName: '/Common/localhost.localdomain', + physicalMemory: 7168, + platform: 'Z100', + product: 'BIG-IP', + trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', + version: '15.1.0' + } + ); + }); }); - sinon.stub(deviceUtil, 'getDeviceVersion').resolves({ version: '14.0.0' }); - return deviceUtil.gatherHostDeviceInfo() - .then(() => deviceUtil.getDeviceType()) - .then((deviceType) => { - assert.strictEqual(deviceType, constants.DEVICE_TYPE.BIG_IP, 'incorrect device type, should be BIG-IP'); - assert.strictEqual(readFileStub.callCount, 1); + + describe('.getDeviceVersion()', () => { + beforeEach(() => { + bigip.mockDeviceVersion(); + deviceUtil.clearHostDeviceInfo(); }); - }); - }); - describe('.downloadFileFromDevice()', () => { - const dstPath = `${os.tmpdir()}/testDownloadFileUserStream`; - const cleanUp = () => { - if (fs.existsSync(dstPath)) { - fs.unlinkSync(dstPath); - } - }; - - beforeEach(cleanUp); - afterEach(cleanUp); - - it('should fail to write data to file', () => { - const response = 'response'; - testUtil.mockEndpoints([{ - endpoint: '/uri/to/path', - response, - responseHeaders: { - 'content-range': `0-${response.length - 1}/${response.length}`, - 'content-length': response.length - } - }]); - return assert.isRejected( - deviceUtil.downloadFileFromDevice('/non-existing/path', constants.LOCAL_HOST, '/uri/to/path'), - /downloadFileFromDevice.*no such file or directory/ - ); - }); + it('should return device version', async () => { + const ret = await deviceUtil.getDeviceVersion(host, makeRequestOptions()); + assert.deepStrictEqual(ret, defaultVersionData); + }); + }); - it('should fail on invalid response on attempt to download file', () => { - const response = 'response'; - testUtil.mockEndpoints([{ - endpoint: '/uri/to/path', - response, - responseHeaders: { - 'content-length': response.length - } - }]); - return assert.isRejected( - deviceUtil.downloadFileFromDevice(fs.createWriteStream(dstPath), constants.LOCAL_HOST, '/uri/to/path'), - /HTTP Error:/ - ); - }); + describe('.isShellEnabled()', () => { + let shellMock; - it('should able to download file to provided stream', () => { - const response = 'response'; - testUtil.mockEndpoints([{ - endpoint: '/uri/to/path', - response, - responseHeaders: { - 'content-range': `0-${response.length - 1}/${response.length}`, - 'content-length': response.length - } - }]); - return deviceUtil.downloadFileFromDevice(dstPath, constants.LOCAL_HOST, '/uri/to/path') - .then(() => { - const contents = fs.readFileSync(dstPath); - assert.ok(contents.equals(Buffer.from(response)), 'should equal to origin Buffer'); + beforeEach(() => { + shellMock = bigip.mockIsShellEnabled(true); }); - }); - it('should fail to download file when content-range is invalid', () => { - const response = 'response'; - testUtil.mockEndpoints([{ - endpoint: '/uri/to/path', - response, - responseHeaders: { - 'content-range': `0-${response.length - 3}/${response.length - 2}`, - 'content-length': response.length - }, - options: { - times: 2 - } - }]); - return assert.isRejected( - deviceUtil.downloadFileFromDevice(dstPath, constants.LOCAL_HOST, '/uri/to/path'), - /Exceeded expected size/ - ); - }); + it('should return true when shell enabled', () => assert.becomes( + deviceUtil.isShellEnabled(host, makeRequestOptions()), + true + )); - it('should fail to download file (response code !== 200)', () => { - const response = 'response'; - testUtil.mockEndpoints([{ - endpoint: '/uri/to/path', - code: 404, - response, - responseHeaders: { - 'content-range': `0-${response.length - 1}/${response.length}`, - 'content-length': response.length - }, - options: { - times: 5 - } - }]); - return assert.isRejected( - deviceUtil.downloadFileFromDevice(dstPath, constants.LOCAL_HOST, '/uri/to/path'), - /Exceeded number of attempts on HTTP error/ - ); - }); - }); + it('should return false when shell disabled', async () => { + shellMock.shellEnabled = false; + await assert.becomes( + deviceUtil.isShellEnabled(host, makeRequestOptions()), + false + ); + }); - describe('.runTMUtilUnixCommand()', () => { - it('should fail on attempt to execute invalid unix command', () => assert.throws( - () => deviceUtil.runTMUtilUnixCommand('cp'), - /runTMUtilUnixCommand: invalid command/ - )); + it('should return false when unable to read "value" property', async () => { + shellMock.stub.returns([200, { data: 'something' }]); + await assert.becomes( + deviceUtil.isShellEnabled(host, makeRequestOptions()), + false + ); + }); + }); - it('should fail on attempt to list non-existing folder', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1' - }, - response: { - commandResult: '/bin/ls: cannot access /config1: No such file or directory\n' - } - }]); - return assert.isRejected( - deviceUtil.runTMUtilUnixCommand('ls', '/config1', constants.LOCAL_HOST), - /No such file or directory/ - ); - }); + describe('.makeDeviceRequest()', () => { + const endpointURI = '/something'; + let endpointStub; - it('should fail on attempt to move non-existing folder', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-mv', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1 /config2' - }, - response: { - commandResult: 'some error here' - } - }]); - return assert.isRejected( - deviceUtil.runTMUtilUnixCommand('mv', '/config1 /config2', constants.LOCAL_HOST), - /some error here/ - ); - }); + beforeEach(() => { + endpointStub = bigip.mockArbitraryEndpoint({ + authCheck: false, + method: 'GET', + path: endpointURI, + response: () => [200, { sucess: true }] + }); + }); - it('should fail on attempt to remove non-existing folder', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1' - }, - response: { - commandResult: 'some error here' + if (typeof connectionConf.value === 'undefined' && typeof credentialsConf.value === 'undefined') { + it('should use default options', async () => { + await assert.becomes( + deviceUtil.makeDeviceRequest(host, endpointURI), + { sucess: true } + ); + + assert.deepStrictEqual(endpointStub.stub.callCount, 1); + assert.deepStrictEqual( + endpointStub.stub.args[0][2].headers.authorization, + `Basic ${Buffer.from(`${defaultUser}:`).toString('base64')}` + ); + }); } - }]); - return assert.isRejected( - deviceUtil.runTMUtilUnixCommand('rm', '/config1', constants.LOCAL_HOST), - /some error here/ - ); - }); - it('should pass on attempt to remove/move folder', () => { - testUtil.mockEndpoints([{ - endpoint: /\/mgmt\/tm\/util\/unix-(rm|mv)/, - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1' - }, - response: {}, - options: { - times: 2 - } - }]); - return assert.isFulfilled(deviceUtil.runTMUtilUnixCommand('rm', '/config1', constants.LOCAL_HOST) - .then(() => deviceUtil.runTMUtilUnixCommand('mv', '/config1', constants.LOCAL_HOST))); - }); + it('should use token instead of username', async () => { + await assert.becomes( + deviceUtil.makeDeviceRequest(host, endpointURI, makeRequestOptions({ + credentials: { + token: 'validToken' + } + })), + { sucess: true } + ); - it('should pass on attempt to list folder', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1' - }, - response: { - commandResult: 'file1\nfile2\n' - } - }]); - return assert.becomes( - deviceUtil.runTMUtilUnixCommand('ls', '/config1', constants.LOCAL_HOST), - ['file1', 'file2'] - ); - }); + assert.deepStrictEqual(endpointStub.stub.callCount, 1); + assert.deepStrictEqual(endpointStub.stub.args[0][2].headers['x-f5-auth-token'], 'validToken'); + }); - it('should not split output into array on attempt to list folder', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '/config1' - }, - response: { - commandResult: 'file1\nfile2\n' + if (hostConf.value === localhost) { + it('should correctly encode username for auth header', async () => { + await assert.becomes( + deviceUtil.makeDeviceRequest(host, endpointURI, makeRequestOptions({ + credentials: { + username: 'username' + } + })), + { sucess: true } + ); + + assert.deepStrictEqual(endpointStub.stub.callCount, 1); + assert.deepStrictEqual( + endpointStub.stub.args[0][2].headers.authorization, + `Basic ${Buffer.from('username:').toString('base64')}` + ); + }); } - }]); - return assert.becomes( - deviceUtil.runTMUtilUnixCommand('ls', '/config1', constants.LOCAL_HOST, { splitLsOutput: false }), - 'file1\nfile2\n' - ); - }); - }); - describe('.getDeviceVersion()', () => { - it('should return device version', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/sys/version', - response: { - entries: { - someKey: { - nestedStats: { - entries: { - version: { - description: '14.1.0' - }, - BuildInfo: { - description: '0.0.1' - } - } - } + it('should not include auth data', async () => { + let opts = { + credentials: { + username: 'username' } + }; + if (host === remotehost) { + opts = { + credentials: { + token: 'validToken' + } + }; } - } - }]); - const expected = { - version: '14.1.0', - buildInfo: '0.0.1' - }; - return assert.becomes( - deviceUtil.getDeviceVersion(constants.LOCAL_HOST), - expected - ); - }); - }); - describe('.makeDeviceRequest()', () => { - it('should preserve device\'s default port, protocol, HTTP method and etc.', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/uri/something', - requestHeaders: { - 'x-f5-auth-token': 'auth-token', - 'User-Agent': constants.USER_AGENT - }, - response: 'something' - }], - { - host: '192.168.0.1', - port: constants.DEVICE_REST_API.PORT, - proto: constants.DEVICE_REST_API.PROTOCOL - } - ); - const opts = { - headers: { - 'x-f5-auth-token': 'auth-token' - }, - credentials: { - token: 'newToken', - username: 'username' - } - }; - return assert.becomes( - deviceUtil.makeDeviceRequest('192.168.0.1', '/uri/something', opts), - 'something' - ); - }); - - it('should use token instead of username', () => { - testUtil.mockEndpoints([{ - endpoint: '/', - requestHeaders: { - 'x-f5-auth-token': 'validToken' - }, - response: 'something' - }]); - const opts = { - credentials: { - token: 'validToken' - } - }; - return assert.becomes( - deviceUtil.makeDeviceRequest('localhost', '/', opts), - 'something' - ); - }); + await assert.becomes( + deviceUtil.makeDeviceRequest( + host, + endpointURI, + makeRequestOptions(Object.assign({ noAuthHeader: true }, opts)) + ), + { sucess: true } + ); - it('should correctly encode username for auth header', () => { - testUtil.mockEndpoints([{ - endpoint: '/', - requestHeaders: { - Authorization: `Basic ${Buffer.from('username:').toString('base64')}` - }, - response: 'something' - }]); - const opts = { - credentials: { - username: 'username' - } - }; - return assert.becomes( - deviceUtil.makeDeviceRequest('localhost', '/', opts), - 'something' - ); - }); - }); + assert.deepStrictEqual(endpointStub.stub.callCount, 1); + assert.isUndefined(endpointStub.stub.args[0][2].headers.authorization); + assert.isUndefined(endpointStub.stub.args[0][2].headers['x-f5-auth-token']); + }); - describe('.executeShellCommandOnDevice()', () => { - it('should execute shell command', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "echo something"' - }, - response: { - commandResult: 'something' + if (hostConf.value === localhost) { + it('should use default username when no auth data provided', async () => { + await assert.becomes( + deviceUtil.makeDeviceRequest(host, endpointURI, Object.assign({}, connection)), + { sucess: true } + ); + + assert.deepStrictEqual(endpointStub.stub.callCount, 1); + assert.deepStrictEqual( + endpointStub.stub.args[0][2].headers.authorization, + `Basic ${Buffer.from('admin:').toString('base64')}` + ); + }); } - }]); - return assert.becomes( - deviceUtil.executeShellCommandOnDevice(constants.LOCAL_HOST, 'echo something'), - 'something' - ); - }); - }); - describe('.getAuthToken()', () => { - it('should get an auth token', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/authn/login', - code: 200, - method: 'post', - request: { - username: 'username', - password: 'password', - loginProviderName: 'tmos' - }, - response: { - token: { - token: 'token' + it('should throw on invalid connection options', async () => { + endpointStub.remove(); + await assert.isRejected( + deviceUtil.makeDeviceRequest(host, endpointURI, null), + /options should be an object/ + ); + + const connTests = bigipConnTests(); + for (const test of connTests) { + if (Object.keys(test.value).length !== 0) { + await assert.isRejected( + deviceUtil.makeDeviceRequest(host, endpointURI, Object.assign({ credentials: { token: 'token' } }, test.value)), + test.error + ); } } - }], - { - host: 'example.com' - } - ); - return assert.becomes( - deviceUtil.getAuthToken('example.com', 'username', 'password'), - { token: 'token' } - ); - }); + }); + }); - it('should return null auth token for localhost', () => assert.becomes( - deviceUtil.getAuthToken('localhost'), - { token: null } - )); + describe('.pathExists()', () => { + let pathExistsMock; - it('should fail to get auth token when no username and/or no password', () => assert.isRejected( - deviceUtil.getAuthToken('example.com'), - /getAuthToken: Username/ - )); - }); + beforeEach(() => { + pathExistsMock = bigip.mockPathExists(/testfile/); + }); - describe('.encryptSecret()', () => { - beforeEach(() => { - sinon.stub(crypto, 'randomBytes').returns('test'); - deviceUtil.clearHostDeviceInfo(); - }); + it('should fail when path doesn\'t exist', () => { + pathExistsMock.stub.returns([200, '/bin/ls: testfile doesn\'t exist']); + return assert.isRejected( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions()), + /bin\/ls: testfile/ + ); + }); - it('should use cached device info on attempt to encrypt data', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret['encrypt-14.0.0']); - sinon.stub(deviceUtil, 'getDeviceType').resolves(constants.DEVICE_TYPE.BIG_IP); - return deviceUtil.gatherHostDeviceInfo() - .then(() => deviceUtil.encryptSecret('foo')) - .then((encryptedData) => { - assert.strictEqual(encryptedData, 'secret'); + it('should resolve when path exists (single file)', () => { + pathExistsMock.stub.returns([200, '/something/testfile']); + return assert.becomes( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions()), + ['/something/testfile'] + ); }); - }); - it('should encrypt secret and retrieve it via REST API when software version is 14.0.0', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret['encrypt-14.0.0']); - return assert.becomes( - deviceUtil.encryptSecret('foo', true), - 'secret' - ); - }); + it('should resolve when path exists (multiple files)', () => { + pathExistsMock.stub.returns([200, ['/something/testfile', '/something/testfile2'].join('\n')]); + return assert.becomes( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions()), + ['/something/testfile', '/something/testfile2'] + ); + }); - it('should encrypt secret and retrieve it from device via TMSH when software version is 14.1.x', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret['encrypt-14.1.x']); - return assert.becomes( - deviceUtil.encryptSecret('foo', true), - 'secret' - ); - }); + it('should resolve when path exists (split lines disabled)', () => { + pathExistsMock.stub.returns([200, ['/something/testfile', '/something/testfile2'].join('\n')]); + return assert.becomes( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions({ splitLines: false })), + '/something/testfile\n/something/testfile2' + ); + }); - it('should encrypt secret and retrieve it via REST API when software version is 15.0.0', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret['encrypt-15.0.0']); - return assert.becomes( - deviceUtil.encryptSecret('foo', true), - 'secret' - ); - }); + it('should resolve when path does not exists', () => { + pathExistsMock.stub.returns([200, '']); + return assert.becomes( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions()), + [] + ); + }); - it('should encrypt data that is 1k characters long', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret.encrypt1kSecret); - const secret = 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc' - + 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca' - + 'bcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc' - + 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcab' - + 'cabcabcabcabcabcabcabcabca'; - return assert.becomes( - deviceUtil.encryptSecret(secret, true), - 'secret,secret' - ); - }); + it('should resolve when path does not exists (split lines disabled)', () => { + pathExistsMock.stub.returns([200, '']); + return assert.becomes( + deviceUtil.pathExists('/something/testfile', host, makeRequestOptions({ splitLines: false })), + '' + ); + }); + }); - it('should chunk large secrets and preserve newlines when encrypting secrets', () => { - const radiusRequests = []; - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/ltm/auth/radius-server', - method: 'post', - request: (body) => { - radiusRequests.push(body.secret); - return true; - }, - response: { - secret: 'secret' - }, - options: { - times: 2 - } - }]); - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret.encrypt1kSecretWithNewLines); - // secret that is > 500 characters, with newlines - const largeSecret = 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n' - + 'largeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\nlargeSecret123\n'; - - return deviceUtil.encryptSecret(largeSecret, true) - .then(() => { - const requestSecret = radiusRequests[0]; - assert.lengthOf(radiusRequests, 2, 'largeSecret should be in 2 chunks'); - assert.lengthOf(requestSecret, 500, 'length of chunk should be 500'); - assert.ok(/\n/.test(requestSecret), 'newlines should be preserved'); + describe('.removePath()', () => { + let removeMock; + + beforeEach(() => { + removeMock = bigip.mockRemovePath(/testfile/); }); - }); - it('should fail when unable to encrypt secret', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret.errorResponseExample); - return assert.isRejected( - deviceUtil.encryptSecret('foo', true), - /Bad status code: 400/ - ); - }); + it('should fail when unable to remove path', () => { + removeMock.stub.returns([200, 'expected error message']); - it('should fail when encrypted secret has comma', () => { - testUtil.mockEndpoints(deviceUtilTestsData.encryptSecret.errorWhenResponseHasComma); - return assert.isRejected( - deviceUtil.encryptSecret('foo', true), - /Encrypted data should not have a comma in it/ - ); + return assert.isRejected( + deviceUtil.removePath('/something/testfile', host, makeRequestOptions()), + /expected error message/ + ); + }); + + it('should resolve when successfully removed path', () => assert.isFulfilled( + deviceUtil.removePath('/something/testfile', host, makeRequestOptions()) + )); + }); }); }); - describe('.decryptSecret()', () => { - it('should decrypt secret', () => { - sinon.stub(childProcess, 'execFile').callsFake((cmd, args, cb) => { - cb(null, 'secret', null); - }); - return assert.becomes( - deviceUtil.decryptSecret('foo'), - 'secret' - ); + describe('.decryptAllSecrets()', () => { + let decryptStub; + + beforeEach(() => { + const bigip = new BigIpApiMock(); + decryptStub = bigip.mockDecryptSecret(); }); it('should fail when unable to decrypt secret', () => { - sinon.stub(childProcess, 'execFile').callsFake((cmd, args, cb) => { - cb(new Error('decrypt error'), null, 'stderr'); - }); - return assert.isRejected(deviceUtil.decryptSecret('foo'), /decrypt error/); + decryptStub.rejects(new Error('expected decrypt error')); + + return assert.isRejected( + deviceUtil.decryptAllSecrets({ + My_Consumer: { + class: 'Consumer', + passphrase: { + class: 'Secret', + cipherText: 'My_Consumer_Secret' + } + } + }), + /expected decrypt error/ + ); }); - }); - describe('.decryptAllSecrets()', () => { it('should decrypt all secrets', () => { - sinon.stub(childProcess, 'execFile').callsFake((cmd, args, cb) => { - cb(null, 'secret', null); - }); sinon.stub(process, 'env').value({ MY_SECRET_TEST_VAR: 'envSecret' }); - const declaration = { My_Consumer: { class: 'Consumer', passphrase: { class: 'Secret', - cipherText: 'foo' + cipherText: 'My_Consumer_Secret' } }, My_Consumer2: { @@ -703,21 +1256,28 @@ describe('Device Util', () => { class: 'Consumer', passphrase: { class: 'Secret', - someUnknownKey: 'foo' + someUnknownKey: ['My', 'Consumer4', 'test'].join('_') } }, My_Consumer5: { class: 'Consumer', otherkey: { class: 'Secret', - cipherText: 'foo' + cipherText: ['My_Consumer5', 'test_passphrase_2'].join('_') + } + }, + My_Consumer6: { + class: 'Consumer', + otherkey: { + class: 'Secret', + cipherText: ['My_Consumer5_very_very', '_long', '_test_passphrase_1'].join(',') } } }; const expected = { My_Consumer: { class: 'Consumer', - passphrase: 'secret' + passphrase: 'decrypted_My_Consumer_Secret' }, My_Consumer2: { class: 'Consumer', @@ -733,7 +1293,11 @@ describe('Device Util', () => { }, My_Consumer5: { class: 'Consumer', - otherkey: 'secret' + otherkey: ['decrypted', 'My_Consumer5', 'test_passphrase_2'].join('_') + }, + My_Consumer6: { + class: 'Consumer', + otherkey: ['decrypted', 'My_Consumer5', 'very_very_long_test_passphrase_1'].join('_') } }; return assert.becomes( @@ -743,762 +1307,157 @@ describe('Device Util', () => { }); }); - describe('.transformTMOSobjectName()', () => { - it('should fail when subPath passed without partition', () => assert.throws( - () => deviceUtil.transformTMOSobjectName('', '', 'subPath'), - /transformTMOSobjectName:/ - )); - - it('should correctly transform TMOS object name', () => { - assert.strictEqual(deviceUtil.transformTMOSobjectName('partition', 'name', 'subPath'), '~partition~subPath~name'); - assert.strictEqual(deviceUtil.transformTMOSobjectName('partition', '/name/name', 'subPath'), '~partition~subPath~~name~name'); - assert.strictEqual(deviceUtil.transformTMOSobjectName('partition', '/name/name', 'subPath'), '~partition~subPath~~name~name'); - assert.strictEqual(deviceUtil.transformTMOSobjectName('', 'name'), 'name'); - }); - }); - - describe('.pathExists()', () => { - it('should fail when path doesn\'t exist', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"testPath"' - }, - response: { - commandResult: '/bin/ls: testPath doesn\'t exist' - } - }]); - return assert.isRejected( - deviceUtil.pathExists('testPath', constants.LOCAL_HOST), - /bin\/ls: testPath/ - ); - }); - - it('should resolve when path exists', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"testPath"' - }, - response: { - commandResult: 'testPath' - } - }]); - return assert.isFulfilled(deviceUtil.pathExists('testPath', constants.LOCAL_HOST)); - }); - }); - - describe('.removePath()', () => { - it('should fail when unable to remove path', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"path"' - }, - response: { - commandResult: 'some error message' - } - }]); - return assert.isRejected( - deviceUtil.removePath('path', constants.LOCAL_HOST), - /some error message/ - ); - }); - - it('should resolve when successfully removed path', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"path"' - }, - response: {} - }]); - return assert.isFulfilled(deviceUtil.removePath('path', constants.LOCAL_HOST)); - }); - }); - - describe('.getDeviceInfo()', () => { - it('should fetch device info', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }]); - return assert.becomes( - deviceUtil.getDeviceInfo(constants.LOCAL_HOST), - { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0' - } - ); - }); - }); -}); - -// purpose: validate util (DeviceAsyncCLI) -describe('Device Util (DeviceAsyncCLI)', () => { - afterEach(() => { - sinon.restore(); - }); - - let parseURL; - if (process.versions.node.startsWith('4.')) { - parseURL = urllib.parse; - } else { - parseURL = (url) => new urllib.URL(url); - } - - const testScriptName = 'testScriptName'; - const testScriptCode = 'testScriptCode'; - const testTaskID = 'taskID'; - const mockedHTTPmethods = ['get', 'post', 'put', 'delete', 'patch']; - - let testsParametrization = [ - { - objectMethod: '_createTemporaryCLIscriptOnDevice', - args: [ - [{ name: testScriptName, code: testScriptCode }] - ], - uri: [ - { - uri: '/mgmt/tm/cli/script', - method: { - POST: [ - { - success: true, - response: { - code: [200, 404, 409], - body: ['something', { code: 404 }, { code: 409 }, {}] - } - } - ] - } - } - ] - }, + // connection config + [ { - objectMethod: '_updateTemporaryCLIscriptOnDevice', - args: [ - [{ name: testScriptName, code: testScriptCode, opts: { partition: '', subPath: '' } }], - [{ name: testScriptName, code: testScriptCode, opts: { partition: 'Common', subPath: '' } }], - [{ name: testScriptName, code: testScriptCode, opts: { partition: 'Common', subPath: 'subPath' } }] - ], - uri: [ - { - uri: [ - '/mgmt/tm/cli/script/testScriptName', - '/mgmt/tm/cli/script/~Common~testScriptName', - '/mgmt/tm/cli/script/~Common~subPath~testScriptName' - ], - method: { - PUT: [ - { - success: true, - response: { - code: 200, - body: {} - } - }, - { - success: false, - errMsg: 'Failed to update temporary cli script on device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] - }, - { - objectMethod: '_createAsyncTaskOnDevice', - args: [ - [{ name: testScriptName }, 'command'] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script', - method: { - POST: [ - { - success: true, - response: { - code: 200, - body: { _taskId: testTaskID } - } - }, - { - success: false, - errMsg: 'Failed to create the async task on the device', - response: { - code: 200, - body: ['something', {}] - } - } - ] - } - } - ] - }, - { - objectMethod: '_execAsyncTaskOnDevice', - args: [ - [testTaskID] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID', - method: { - PUT: [ - { - success: true, - response: { - code: [200, 400], - body: {} - } - }, - { - success: false, - errMsg: 'Failed to execute the async task on the device', - response: { - code: [200, 400], - body: 'something' - } - } - ] - } - } - ] - }, - { - objectMethod: '_waitForAsyncTaskToFinishOnDevice', - args: [ - [testTaskID, 0] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID/result', - method: { - GET: [ - { - success: true, - response: { - code: [200, 400], - body: [{ _taskState: 'COMPLETED' }, { _taskState: 'INPROGRESS' }, 'something', {}] - } - }, - { - success: false, - errMsg: 'Task failed unexpectedly', - response: { - code: [200, 400], - body: { _taskState: 'FAILED' } - } - } - ] - } - } - ] - }, - { - objectMethod: '_removeTemporaryCLIscriptFromDevice', - args: [ - [{ name: testScriptName, code: testScriptCode, opts: { partition: '', subPath: '' } }], - [{ name: testScriptName, code: testScriptCode, opts: { partition: 'Common', subPath: '' } }], - [{ name: testScriptName, code: testScriptCode, opts: { partition: 'Common', subPath: 'subPath' } }] - ], - uri: [ - { - uri: [ - '/mgmt/tm/cli/script/testScriptName', - '/mgmt/tm/cli/script/~Common~testScriptName', - '/mgmt/tm/cli/script/~Common~subPath~testScriptName' - ], - method: { - DELETE: [ - { - success: true, - response: { - code: 200, - body: {} - } - }, - { - success: false, - errMsg: 'Failed to remove the temporary cli script from the device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] - }, - { - objectMethod: '_removeAsyncTaskResultsFromDevice', - args: [ - [testTaskID] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID/result', - method: { - DELETE: [ - { - success: true, - response: { - code: 200, - body: {} - } - }, - { - success: false, - errMsg: 'Failed to delete the async task results from the device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] - }, - { - objectMethod: '_removeAsyncTaskFromDevice', - args: [ - [testTaskID] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID', - method: { - DELETE: [ - { - success: true, - response: { - code: 200, - body: {} - } - }, - { - success: false, - errMsg: 'Failed to delete the async task from the device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] - }, - { - objectMethod: '_removeAsyncTaskResultsFromDevice', - testName: '_removeAsyncTaskResultsFromDevice + errOk === true', - args: [ - [testTaskID, true] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID/result', - method: { - DELETE: [ - { - success: true, - errMsg: 'Failed to delete the async task results from the device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] + name: 'default', + value: undefined }, { - objectMethod: '_removeAsyncTaskFromDevice', - testName: '_removeAsyncTaskFromDevice + errOk === true', - args: [ - [testTaskID, true] - ], - uri: [ - { - uri: '/mgmt/tm/task/cli/script/taskID', - method: { - DELETE: [ - { - success: true, - errMsg: 'Failed to delete the async task from the device', - response: { - code: 404, - body: {} - } - } - ] - } - } - ] + name: 'non default', + value: { port: 8105, protocol: 'https' } } - ]; - - function cloneExpectedResponses(expectedResponse, result) { - let multiItems; - let multiKey; - - result = result === undefined ? [] : result; + ].forEach((connectionConf) => { + describe(`.getAuthToken() (connection = ${connectionConf.name})`, () => { + const host = 'remote.host'; + const user1 = { + username: 'test_user_1', + passphrase: 'test_passphrase_1' + }; + const user2 = { + username: 'test_user_2', + passphrase: 'test_passphrase_2' + }; + let authMock; + let connection; - if (Array.isArray(expectedResponse.response.code)) { - multiItems = expectedResponse.response.code; - multiKey = 'code'; - } else if (Array.isArray(expectedResponse.response.body)) { - multiItems = expectedResponse.response.body; - multiKey = 'body'; - } - if (!multiItems) { - const copy = JSON.parse(JSON.stringify(expectedResponse)); - copy.id = result.length; - result.push(copy); - } else { - multiItems.forEach((item) => { - const newResponse = JSON.parse(JSON.stringify(expectedResponse)); - newResponse.response[multiKey] = item; - cloneExpectedResponses(newResponse, result); - }); - } - } - - function createTestData(testParams) { - const newTestURIs = {}; - let totalResponses = 0; - testParams.uri.forEach((testURIdata) => { - const testMethods = testURIdata.method; - Object.keys(testMethods).forEach((testMethod) => { - const responses = testMethods[testMethod]; - testMethods[testMethod] = []; - responses.forEach((oldResponse) => { - cloneExpectedResponses(oldResponse, testMethods[testMethod]); - }); - totalResponses += testMethods[testMethod].length; - }); - if (Array.isArray(testURIdata.uri)) { - testURIdata.uri.forEach((testURI) => { - newTestURIs[testURI] = testURIdata.method; - }); - } else { - newTestURIs[testURIdata.uri] = testURIdata.method; + function makeRequestOptions(options = {}) { + if (Object.keys(options).length === 0 && typeof connection === 'undefined') { + return undefined; + } + return Object.assign({}, connection, options); } - }); - testParams.uri = newTestURIs; - testParams.totalResponses = totalResponses; - - let newTestParams = [testParams]; - - if (Array.isArray(testParams.args)) { - if (testParams.args.length === 1) { - testParams.args = testParams.args[0]; - } else { - newTestParams = []; - let i = 0; - - testParams.args.forEach((argSet) => { - newTestParams.push({ - objectMethod: testParams.objectMethod, - name: `${testParams.objectMethod} args[${i}]`, - args: argSet, - totalResponses: testParams.totalResponses, - uri: testParams.uri - }); - i += 1; + beforeEach(() => { + connection = connectionConf.value; + const bigip = new BigIpApiMock(host, { + port: (connection && connection.port) || undefined, + protocol: (connection && connection.protocol) || undefined }); - } - } - return newTestParams; - } - - const newTestsParametrization = []; - testsParametrization.forEach((t) => { - createTestData(t).forEach((td) => { - newTestsParametrization.push(td); - }); - }); - testsParametrization = newTestsParametrization; - - function mockedResponse(uris, options) { - const touchedResponses = {}; + authMock = bigip.mockAuth(/test_user/, /test_passphrase/); + }); - return function (opts, cb) { - const method = opts.method; - const pathname = parseURL(opts.uri).pathname; - const counters = options.counters; - const expectedErrors = options.expectedErrors; + it('should get an auth token', async () => { + let token = 0; - let responses; - let response; + authMock.interceptor.times(2); + authMock.stub.callsFake(() => { + token += 1; + return [200, { token: { token: `${token}` } }]; + }); - try { - responses = uris[pathname][method]; - } catch (err) { - // do nothing - } + await assert.becomes( + deviceUtil.getAuthToken(host, user1.username, user1.passphrase, makeRequestOptions()), + { token: '1' } + ); - if (!responses) { - throw new Error(`No response for ${method} ${pathname}`); - } + await assert.becomes( + deviceUtil.getAuthToken(host, user2.username, user2.passphrase, makeRequestOptions()), + { token: '2' } + ); + }); - if (touchedResponses[pathname] === undefined) { - touchedResponses[pathname] = []; - } + it('should not cache tokens', async () => { + let token = 0; - for (let i = 0; i < responses.length; i += 1) { - response = responses[i]; - if (!touchedResponses[pathname][response.id]) { - touchedResponses[pathname][response.id] = 1; - counters.touched += 1; - break; - } - } + authMock.interceptor.times(2); + authMock.stub.callsFake(() => { + token += 1; + return [200, { token: { token: `${token}` } }]; + }); - if (!response.success) { - expectedErrors.push(response.errMsg); - } - const res = { - statusCode: response.response.code, - statusMessage: `MOCK HTTP ${method}` - }; - cb(null, res, response.response.body); - }; - } + await assert.becomes( + deviceUtil.getAuthToken(host, user1.username, user1.passphrase, makeRequestOptions()), + { token: '1' } + ); - function runMethodTesting(testData, options) { - const expectedErrors = options.expectedErrors; + await assert.becomes( + deviceUtil.getAuthToken(host, user1.username, user1.passphrase, makeRequestOptions()), + { token: '2' } + ); + }); - if (expectedErrors.length) { - const msg = 'expectedErrors are not empty, looks like something went wrong.'; - assert.fail(msg); - return Promise.reject(new Error(msg)); - } - // ideally all testSets should be covered - const dacli = new deviceUtil.DeviceAsyncCLI('localhost'); - const args = testData.args || [null]; - - return deviceUtil.DeviceAsyncCLI.prototype[testData.objectMethod].apply(dacli, args) - .catch((err) => { - let expected = false; - for (let i = 0; i < expectedErrors.length; i += 1) { - if (err.message.search(expectedErrors[i]) !== -1) { - expected = true; - break; - } - } - options.expectedErrors = []; + it('should return null auth token for localhost', async () => { + authMock.remove(); - if (!expected) { - const msg = `Unexpected error: ${err.message}. Expected: ${JSON.stringify(expectedErrors)}`; - assert.fail(msg); - return Promise.reject(new Error(msg)); - } - return Promise.resolve(); - }) - .then(() => { - if (options.counters.touched < options.validResponsesNo) { - return runMethodTesting(testData, options); - } - return Promise.resolve(); + await assert.becomes( + deviceUtil.getAuthToken(constants.LOCAL_HOST), + { token: null } + ); }); - } - - testsParametrization.forEach((testParams) => { - it(`should pass basic response test for ${testParams.testName || testParams.objectMethod}`, () => { - const uris = testParams.uri; - const options = { - expectedErrors: [], - counters: { touched: 0 }, - validResponsesNo: testParams.totalResponses - }; - const responder = mockedResponse(uris, options); - mockedHTTPmethods.forEach((method) => { - sinon.stub(request, method).callsFake(responder); + it('should fail to get auth token when no username and/or no passphrase', async () => { + authMock.remove(); + + await assert.isRejected(deviceUtil.getAuthToken(host), /username should be a string/); + await assert.isRejected(deviceUtil.getAuthToken(host, ''), /username should be a non-empty/); + await assert.isRejected(deviceUtil.getAuthToken(host, 'test_user_1'), /passphrase should be a string/); + await assert.isRejected(deviceUtil.getAuthToken(host, 'test_user_1', ''), /passphrase should be a non-empty/); }); - return runMethodTesting(testParams, options); }); }); - it('should pass basic response test for execute', () => { - const responseBody = { - _taskId: testTaskID, - _taskState: 'COMPLETED' - }; - const response = { - statusCode: 200, - statusMessage: 'MOCK HTTP' - }; - - const responder = (opts, cb) => { - cb(null, response, responseBody); - }; - mockedHTTPmethods.forEach((method) => { - sinon.stub(request, method).callsFake(responder); - }); - - // ideally all testSets should be covered - const dacli = new deviceUtil.DeviceAsyncCLI('localhost'); - dacli.scriptName = testScriptName; - dacli.retryDelay = 0; - return dacli.execute('command'); - }); + describe('.getDeviceType()', () => { + let bigip; + let deviceTypeStub; - it('should work with token', () => { - const responseBody = { - _taskId: testTaskID, - _taskState: 'COMPLETED' - }; - const response = { - statusCode: 200, - statusMessage: 'MOCK HTTP' - }; - - const responder = (opts, cb) => { - cb(null, response, responseBody); - }; - mockedHTTPmethods.forEach((method) => { - sinon.stub(request, method).callsFake(responder); + beforeEach(() => { + bigip = new BigIpApiMock(constants.LOCAL_HOST); + deviceTypeStub = bigip.mockDeviceType(); + deviceUtil.clearHostDeviceInfo(); }); - // ideally all testSets should be covered - const dacli = new deviceUtil.DeviceAsyncCLI('localhost', { credentials: { token: 'token' } }); - dacli.scriptName = testScriptName; - dacli.retryDelay = 0; - return dacli.execute('command'); - }); - - it('should work with empty host', () => { - const responseBody = { - _taskId: testTaskID, - _taskState: 'COMPLETED' - }; - const response = { - statusCode: 200, - statusMessage: 'MOCK HTTP' - }; - - const responder = (opts, cb) => { - cb(null, response, responseBody); - }; - mockedHTTPmethods.forEach((method) => { - sinon.stub(request, method).callsFake(responder); + it('should get container device type when /VERSION file is absent', () => { + deviceTypeStub.rejects(new Error('expected read file error')); + return assert.becomes( + deviceUtil.getDeviceType(), + constants.DEVICE_TYPE.CONTAINER + ); }); - // ideally all testSets should be covered - const dacli = new deviceUtil.DeviceAsyncCLI(); - dacli.scriptName = testScriptName; - dacli.retryDelay = 0; - dacli.token = 'token'; - return dacli.execute('command'); - }); - - it('should fail when response with error', () => { - const responseBody = { - _taskId: testTaskID, - _taskState: 'FAILED', - code: 409 - }; - const response = { - statusCode: 404, - statusMessage: 'MOCK HTTP' - }; - - const responder = (opts, cb) => { - cb(null, response, responseBody); - }; - mockedHTTPmethods.forEach((method) => { - sinon.stub(request, method).callsFake(responder); + it('should get container device type when /VERSION has no desired data', () => { + deviceTypeStub.returns(['something else']); + return assert.becomes( + deviceUtil.getDeviceType(), + constants.DEVICE_TYPE.CONTAINER + ); }); - // ideally all testSets should be covered - const dacli = new deviceUtil.DeviceAsyncCLI('localhost'); - dacli.scriptName = testScriptName; - dacli.retryDelay = 0; - return assert.isRejected(dacli.execute('command')); - }); - - it('should parse init params correctly', () => { - let dacli = new deviceUtil.DeviceAsyncCLI(); - assert.strictEqual(dacli.host, constants.LOCAL_HOST); - - dacli = new deviceUtil.DeviceAsyncCLI({}); - assert.strictEqual(dacli.host, constants.LOCAL_HOST); - - dacli = new deviceUtil.DeviceAsyncCLI('host'); - assert.strictEqual(dacli.host, 'host'); - - dacli = new deviceUtil.DeviceAsyncCLI({ opts: 'opts' }); - assert.strictEqual(dacli.options.opts, 'opts'); + it('should get BIG-IP device type', () => assert.becomes( + deviceUtil.getDeviceType(), + constants.DEVICE_TYPE.BIG_IP + )); - dacli = new deviceUtil.DeviceAsyncCLI(); - assert.deepStrictEqual(dacli.options, { connection: {}, credentials: {} }); + it('should read result from cache', async () => { + bigip.addPasswordlessUser('admin'); + bigip.mockDeviceVersion(); - dacli = new deviceUtil.DeviceAsyncCLI({}); - assert.deepStrictEqual(dacli.options, { connection: {}, credentials: {} }); + await deviceUtil.gatherHostDeviceInfo(); + await assert.becomes(deviceUtil.getDeviceType(), constants.DEVICE_TYPE.BIG_IP); + assert.deepStrictEqual(deviceTypeStub.callCount, 1); + }); - dacli = new deviceUtil.DeviceAsyncCLI({ something: 'something' }); - assert.deepStrictEqual(dacli.options, { connection: {}, credentials: {}, something: 'something' }); + it('should not read result from cache', async () => { + bigip.addPasswordlessUser('admin'); + bigip.mockDeviceVersion(); - dacli = new deviceUtil.DeviceAsyncCLI({ connection: { port: 80 }, credentials: { token: 'token' } }); - assert.deepStrictEqual(dacli.options, { connection: { port: 80 }, credentials: { token: 'token' } }); + await deviceUtil.gatherHostDeviceInfo(); + await assert.becomes(deviceUtil.getDeviceType(true), constants.DEVICE_TYPE.BIG_IP); + assert.deepStrictEqual(deviceTypeStub.callCount, 1); - dacli = new deviceUtil.DeviceAsyncCLI({ connection: { port: 80 } }); - assert.deepStrictEqual(dacli.options, { connection: { port: 80 }, credentials: {} }); + deviceTypeStub.callsFake(() => Buffer.from('test')); + await assert.becomes(deviceUtil.getDeviceType(false), constants.DEVICE_TYPE.CONTAINER); + assert.deepStrictEqual(deviceTypeStub.callCount, 2); + }); }); }); diff --git a/test/unit/utils/eventEmitterTests.js b/test/unit/utils/eventEmitterTests.js index aefeb511..cef1fb7e 100644 --- a/test/unit/utils/eventEmitterTests.js +++ b/test/unit/utils/eventEmitterTests.js @@ -19,14 +19,18 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); +const sinon = require('sinon'); + const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); const SafeEventEmitter = sourceCode('src/lib/utils/eventEmitter'); moduleCache.remember(); describe('Safe Event Emitter', () => { + let coreStub; const eventName = 'eventName'; let emitter; @@ -35,33 +39,108 @@ describe('Safe Event Emitter', () => { }); beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true }); emitter = new SafeEventEmitter(); }); afterEach(() => { emitter.removeAllListeners(eventName); + sinon.restore(); + }); + + describe('.emitAsync()', () => { + it('should reject with listener error (sync error)', async () => { + const error = new Error('test error'); + emitter.on(eventName, () => { throw error; }); + await assert.isRejected( + emitter.emitAsync(eventName), + /test error/, + 'should reject with error' + ); + }); + + it('should reject with listener error (async error)', async () => { + const error = new Error('test error'); + emitter.on(eventName, () => new Promise((resolve, reject) => { + setTimeout(() => reject(error), 10); + })); + await assert.isRejected( + emitter.emitAsync(eventName), + /test error/, + 'should reject with error' + ); + }); }); - describe('safeEmit', () => { - it('should catch listener error', () => { + describe('.safeEmit()', () => { + it('should catch listener error', async () => { + const error = new Error('test error'); + emitter.on(eventName, () => { throw error; }); + const ret = emitter.safeEmit(eventName); + assert.isTrue(error === ret, 'should return error'); + }); + + it('should log error', async () => { + emitter.logger = coreStub.logger.logger.getChild('emitter'); + + coreStub.logger.removeAllMessages(); + assert.isEmpty(coreStub.logger.messages.all); + const error = new Error('test error'); emitter.on(eventName, () => { throw error; }); const ret = emitter.safeEmit(eventName); assert.isTrue(error === ret, 'should return error'); + + assert.includeMatch( + coreStub.logger.messages.error, + /test error/ + ); }); }); - describe('safeEmitAsync', () => { - it('should catch listener error in sync part', () => { + describe('.safeEmitAsync()', () => { + it('should catch listener error in sync part', async () => { + const error = new Error('test error'); + emitter.on(eventName, () => { throw error; }); + await assert.becomes(emitter.safeEmitAsync(eventName), error); + }); + + it('should log error (sync)', async () => { + emitter.logger = coreStub.logger.logger.getChild('emitter'); + + coreStub.logger.removeAllMessages(); + assert.isEmpty(coreStub.logger.messages.all); + const error = new Error('test error'); emitter.on(eventName, () => { throw error; }); - return assert.becomes(emitter.safeEmitAsync(eventName), error); + await assert.becomes(emitter.safeEmitAsync(eventName), error); + + assert.includeMatch( + coreStub.logger.messages.error, + /test error/ + ); + }); + + it('should catch listener error in async part', async () => { + const error = new Error('test error'); + emitter.on(eventName, () => new Promise((resolve, reject) => { reject(error); })); + await assert.becomes(emitter.safeEmitAsync(eventName), error); }); - it('should catch listener error in async part', () => { + it('should log error (async)', async () => { + emitter.logger = coreStub.logger.logger.getChild('emitter'); + + coreStub.logger.removeAllMessages(); + assert.isEmpty(coreStub.logger.messages.all); + const error = new Error('test error'); emitter.on(eventName, () => new Promise((resolve, reject) => { reject(error); })); - return assert.becomes(emitter.safeEmitAsync(eventName), error); + await assert.becomes(emitter.safeEmitAsync(eventName), error); + + assert.includeMatch( + coreStub.logger.messages.error, + /test error/ + ); }); }); }); diff --git a/test/unit/consumers/httpUtilTests.js b/test/unit/utils/httpTests.js similarity index 74% rename from test/unit/consumers/httpUtilTests.js rename to test/unit/utils/httpTests.js index fa71de8c..bf33a687 100644 --- a/test/unit/consumers/httpUtilTests.js +++ b/test/unit/utils/httpTests.js @@ -28,19 +28,28 @@ const sourceCode = require('../shared/sourceCode'); const testUtil = require('../shared/util'); const constants = sourceCode('src/lib/constants'); -const httpUtil = sourceCode('src/lib/consumers/shared/httpUtil'); +const httpUtil = sourceCode('src/lib/utils/http'); const util = sourceCode('src/lib/utils/misc'); moduleCache.remember(); +// default values for http/https Agent +const LIB_DEFAULTS = { + keepAliveMsecs: 1000, + keepAlive: false, + maxSockets: Infinity, + maxFreeSockets: 256, + protocol: 'http:' +}; + describe('HTTP Util Tests', () => { before(() => { moduleCache.restore(); }); afterEach(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); }); @@ -193,7 +202,7 @@ describe('HTTP Util Tests', () => { return httpUtil.sendToConsumer(config) .then(() => { // force nock cleanup because not all mocks were used - nock.cleanAll(); + testUtil.nockCleanup(); assert.strictEqual(called, 2, 'should stop on fallbackHost1'); }); }); @@ -217,7 +226,7 @@ describe('HTTP Util Tests', () => { return httpUtil.sendToConsumer(config) .then(() => { // force nock cleanup because not all mocks were used - nock.cleanAll(); + testUtil.nockCleanup(); assert.strictEqual(called, 2, 'should stop on fallbackHost1'); }); }); @@ -311,4 +320,84 @@ describe('HTTP Util Tests', () => { }); }); }); + + describe('.getAgent', () => { + const assertOptValues = (agent, specifiedProps) => { + const expected = Object.assign({}, LIB_DEFAULTS, specifiedProps); + const actual = { + keepAlive: agent.keepAlive, + keepAliveMsecs: agent.keepAliveMsecs, + maxFreeSockets: agent.maxFreeSockets, + maxSockets: agent.maxSockets, + protocol: agent.protocol + }; + assert.deepStrictEqual(actual, expected); + }; + + it('should return a default http agent if no matching opts found in config', () => { + const config = { + someOtherOpts: [{ prop1: 'one', prop2: 'two' }], + connection: { protocol: 'http' } + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[]'); + assertOptValues(httpAgent.agent, {}); + }); + + it('should return a default http agent if no matching protocol and opts in config', () => { + const config = { + someOtherOpts: [{ prop1: 'one', prop2: 'two' }] + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[]'); + assertOptValues(httpAgent.agent, {}); + }); + + it('should return correct agent type based on protocol', () => { + const config = { + httpAgentOpts: [{ name: 'keepAliveMsecs', value: 3000 }], + connection: { protocol: 'https' } + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[["keepAliveMsecs",3000]]', 'should generate opts-based key'); + assertOptValues(httpAgent.agent, { protocol: 'https:', keepAliveMsecs: 3000 }); + }); + + it('should use config.httpAgentOpts if present', () => { + const config = { + httpAgentOpts: [{ name: 'keepAlive', value: true }], + connection: { protocol: 'http' } + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[["keepAlive",true]]', 'should generate opts-based key'); + assertOptValues(httpAgent.agent, { keepAlive: true }); + }); + + it('should use config.customOpts if present', () => { + const config = { + customOpts: [ + { name: 'maxSockets', value: 123 }, + { name: 'maxFreeSockets', value: 1000 } + ], + connection: { protocol: 'http' } + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[["maxFreeSockets",1000],["maxSockets",123]]', 'should generate opts-based key'); + assertOptValues(httpAgent.agent, { maxSockets: 123, maxFreeSockets: 1000 }); + }); + + it('should not error and just ignore unknown keys', () => { + const config = { + customOpts: [ + { name: 'maxSockets', value: 10 }, + { name: 'defaultPort', value: 1000 }, + { name: 'unknown', value: true } + ], + connection: { protocol: 'http' } + }; + const httpAgent = httpUtil.getAgent(config); + assert.strictEqual(httpAgent.agentKey, '[["maxSockets",10]]', 'should generate opts-based key'); + assertOptValues(httpAgent.agent, { maxSockets: 10 }); + }); + }); }); diff --git a/test/unit/utils/ihealthTests.js b/test/unit/utils/ihealthTests.js deleted file mode 100644 index 43881be0..00000000 --- a/test/unit/utils/ihealthTests.js +++ /dev/null @@ -1,1651 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const fs = require('fs'); -const nock = require('nock'); -const path = require('path'); -const sinon = require('sinon'); - -const assert = require('../shared/assert'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const deviceUtil = sourceCode('src/lib/utils/device'); -const ihealthUtil = sourceCode('src/lib/utils/ihealth'); - -moduleCache.remember(); - -describe('iHealth Utils', () => { - const remoteHostName = 'remote.hostname.remote.domain'; - - before(() => { - moduleCache.restore(); - }); - - afterEach(() => { - testUtil.checkNockActiveMocks(nock); - nock.cleanAll(); - sinon.restore(); - }); - - describe('DeviceAPI', () => { - let deviceApiInst; - let deviceGethAuthTokenStub; - - beforeEach(() => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName); - deviceGethAuthTokenStub = sinon.stub(deviceUtil, 'getAuthToken'); - deviceGethAuthTokenStub.resolves({ token: 'token' }); - return deviceApiInst.initialize(); - }); - - describe('constructor', () => { - it('should set defaults', () => { - assert.strictEqual(deviceApiInst.host, remoteHostName, 'should match host value'); - assert.isEmpty(deviceApiInst.connection, 'should have empty connection options'); - assert.deepStrictEqual(deviceApiInst.credentials, { - token: 'token' // see beforeEach - }, 'should have empty credentials'); - }); - - it('should set provided values', () => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName, { - connection: { - allowSelfSignedCert: true, - port: 80, - protocol: 'http' - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - } - }); - - assert.strictEqual(deviceApiInst.host, remoteHostName, 'should match host value'); - assert.deepStrictEqual(deviceApiInst.connection, { - allowSelfSignedCert: true, - port: 80, - protocol: 'http' - }, 'should have connection options'); - assert.deepStrictEqual(deviceApiInst.credentials, { - username: 'username', - passphrase: 'passphrase' - }, 'should have credentials'); - }); - }); - - describe('.buildQkviewCommand()', () => { - it('should build qkview command using provided absolute path', () => { - assert.strictEqual( - deviceApiInst.buildQkviewCommand('/test/qkview/file/path'), - '/usr/bin/qkview -C -f ../../test/qkview/file/path', - 'should match expected command' - ); - }); - }); - - describe('.buildQkviewPath()', () => { - it('should build absolute path to qkview file', () => { - assert.strictEqual( - deviceApiInst.buildQkviewPath('qkview.file.name'), - '/shared/tmp/qkview.file.name', - 'should build expected path' - ); - }); - }); - - describe('.createQkview()', () => { - it('should send command via REST API to create qkview', () => { - const executeStub = sinon.stub(deviceUtil.DeviceAsyncCLI.prototype, 'execute'); - executeStub.resolves(); - return deviceApiInst.createQkview('qkviewFileName') - .then((qkviewPath) => { - assert.strictEqual(qkviewPath, '/shared/tmp/qkviewFileName', 'should return path to Qkview file on remote host'); - assert.strictEqual(executeStub.args[0][0], '/usr/bin/qkview -C -f ../../shared/tmp/qkviewFileName'); - }); - }); - - it('should fail to create qkview', () => { - sinon.stub(deviceUtil.DeviceAsyncCLI.prototype, 'execute').rejects(new Error('qkview error')); - return assert.isRejected( - deviceApiInst.createQkview('qkviewFileName'), - 'qkview error' - ); - }); - }); - - describe('.createSymLink()', () => { - it('should execute shell command', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "ln -s \\"path1\\" \\"path2\\""' - }, - response: { - commandResult: 'success' - } - }], { - host: remoteHostName - }); - return deviceApiInst.createSymLink('path1', 'path2'); - }); - }); - - describe('.downloadFile()', () => { - beforeEach(() => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/path/to/remote/file"' - }, - response: { - commandResult: '/path/to/remote/file' - } - }, - { - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - version: '14.0' - } - }], { - host: remoteHostName - }); - }); - - it('should download file and remove symlink', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "ln -s \\"/path/to/remote/file\\" \\"/var/config/rest/bulk/file\\""' - }, - response: { - commandResult: 'success' - } - }, - { - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/var/config/rest/bulk/file"' - }, - response: { - commandResult: '/var/config/rest/bulk/file' - } - }, - { - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/var/config/rest/bulk/file"' - }, - response: {} - }], { - host: remoteHostName - }); - const downloadUtilStub = sinon.stub(deviceUtil, 'downloadFileFromDevice').resolves(); - return deviceApiInst.downloadFile('/path/to/remote/file', '/path/to/local/file') - .then((localPath) => { - assert.strictEqual(localPath, '/path/to/local/file'); - - const args = downloadUtilStub.args[0]; - assert.strictEqual(args[0], '/path/to/local/file'); - assert.strictEqual(args[1], remoteHostName); - assert.strictEqual(args[2], '/mgmt/shared/file-transfer/bulk/file'); - }); - }); - - it('should remove symlink even when download failed', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "ln -s \\"/path/to/remote/file\\" \\"/var/config/rest/bulk/file\\""' - }, - response: { - commandResult: 'success' - } - }, - { - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/var/config/rest/bulk/file"' - }, - response: { - commandResult: '/var/config/rest/bulk/file' - } - }, - { - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/var/config/rest/bulk/file"' - }, - response: {} - }], { - host: remoteHostName - }); - sinon.stub(deviceUtil, 'downloadFileFromDevice').rejects(new Error('download rejected')); - return assert.isRejected( - deviceApiInst.downloadFile('/path/to/remote/file', '/path/to/local/file'), - /download rejected/ - ); - }); - - it('should not try to remove symlink when unable to create it', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "ln -s \\"/path/to/remote/file\\" \\"/var/config/rest/bulk/file\\""' - }, - code: 400 - }], { - host: remoteHostName - }); - sinon.stub(deviceUtil, 'downloadFileFromDevice').rejects(new Error('download rejected')); - return assert.isRejected( - deviceApiInst.downloadFile('/path/to/remote/file', '/path/to/local/file'), - /Bad status code/ - ); - }); - }); - - describe('.getAuthToken()', () => { - it('should get auth token', () => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName); - return deviceApiInst.initialize() - .then(() => { - assert.deepStrictEqual(deviceApiInst.credentials, { - token: 'token' - }); - }); - }); - - it('should reuse auth token', () => { - const callsBefore = deviceGethAuthTokenStub.callCount; - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName); - return deviceApiInst.getAuthToken() - .then(() => deviceApiInst.getAuthToken()) - .then(() => { - assert.strictEqual(deviceGethAuthTokenStub.callCount - callsBefore, 1, 'should re-use token'); - }); - }); - }); - - describe('.getDACLIOptions()', () => { - it('should generate new script name on each call and copy other options', () => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName, { - connection: { - port: 80, - protocol: 'http', - allowSelfSignedCert: true - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - } - }); - const opts1 = deviceApiInst.getDACLIOptions(); - const opts2 = deviceApiInst.getDACLIOptions(); - - assert.notStrictEqual(opts1.scriptName, opts2.scriptName, 'should generate different name'); - assert.deepStrictEqual(opts1.connection, opts2.connection, 'should be copied'); - assert.deepStrictEqual(opts1.credentials, opts2.credentials, 'should be copied'); - assert.isFalse(opts1.connection === opts2.connection, 'should not have reference to the same object'); - assert.isFalse(opts1.credentials === opts2.credentials, 'should not have reference to the same object'); - }); - }); - - describe('.getDefaultRequestOptions()', () => { - it('should return options for deviceUtil.makeRequest', () => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName, { - connection: { - allowSelfSignedCert: true, - port: 80, - protocol: 'http' - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - } - }); - return deviceApiInst.initialize() - .then(() => { - assert.deepStrictEqual(deviceApiInst.getDefaultRequestOptions(), { - allowSelfSignedCert: true, - credentials: { - token: 'token', // see beforeEach - username: 'username' - }, - port: 80, - protocol: 'http' - }); - }); - }); - - it('should return options for deviceUtil.makeRequest when initialized with empty options', () => { - deviceApiInst = new ihealthUtil.DeviceAPI(remoteHostName); - assert.deepStrictEqual(deviceApiInst.getDefaultRequestOptions(), { - credentials: { - token: undefined, - username: undefined - } - }); - }); - }); - - describe('.getDeviceInfo()', () => { - it('should return device info', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }], { - host: remoteHostName - }); - return assert.becomes( - deviceApiInst.getDeviceInfo(), - { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0' - }, - 'should return expected device info' - ); - }); - }); - - describe('.getDownloadInfo()', () => { - it('should return data for BIG-IP versions older than 14.0', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - version: '13.1' - } - }], { - host: remoteHostName - }); - return assert.becomes( - deviceApiInst.getDownloadInfo(), - { - dir: '/var/config/rest/madm', - uri: '/mgmt/shared/file-transfer/madm/' - }, - 'should return expected download info' - ); - }); - - it('should return data for BIG-IP versions newer than or equal 14.0', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - version: '14.0' - } - }], { - host: remoteHostName - }); - return assert.becomes( - deviceApiInst.getDownloadInfo(), - { - dir: '/var/config/rest/bulk', - uri: '/mgmt/shared/file-transfer/bulk/' - }, - 'should return expected download info' - ); - }); - }); - - describe('.getMD5sum()', () => { - it('should send command via REST API to calculate MD5 sum', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "cat \\"qkviewFileName.md5sum\\""' - }, - response: { - commandResult: 'md5sum "qkviewFileName" > "qkviewFileName.md5sum"' - } - }, - { - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"qkviewFileName.md5sum"' - }, - response: {} - }], { - host: remoteHostName - }); - - const executeStub = sinon.stub(deviceUtil.DeviceAsyncCLI.prototype, 'execute'); - executeStub.resolves(); - - return deviceApiInst.getMD5sum('qkviewFileName') - .then((md5sum) => { - assert.strictEqual(md5sum, 'md5sum'); - assert.strictEqual(executeStub.args[0][0], 'md5sum "qkviewFileName" > "qkviewFileName.md5sum"'); - }); - }); - - it('should reject when unable to calculate MD5 sum', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/bash', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '-c "cat \\"qkviewFileName.md5sum\\""' - }, - response: {} - }, - { - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"qkviewFileName.md5sum"' - }, - response: {} - }], { - host: remoteHostName - }); - - const executeStub = sinon.stub(deviceUtil.DeviceAsyncCLI.prototype, 'execute'); - executeStub.resolves(); - - return assert.isRejected( - deviceApiInst.getMD5sum('qkviewFileName'), - /MD5 file "qkviewFileName.md5sum" is empty/ - ); - }); - }); - - describe('.pathExists()', () => { - it('should resolve if path exists', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/path/to/file"' - }, - response: { - commandResult: '/path/to/file' - } - }], { - host: remoteHostName - }); - return deviceApiInst.pathExists('/path/to/file'); - }); - - it('should fail if path doesn\'t exist', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-ls', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/path/to/file"' - }, - response: { - commandResult: '/path/to/another/file\n/path/to/another/file2' - } - }], { - host: remoteHostName - }); - return assert.isRejected( - deviceApiInst.pathExists('/path/to/file'), - 'pathExists: /path/to/file doesn\'t exist' - ); - }); - }); - - describe('.removeFile()', () => { - it('should remove path', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/path/to/remove"' - }, - response: {} - }], { - host: remoteHostName - }); - return deviceApiInst.removeFile('/path/to/remove'); - }); - - it('should not reject when unable to remove path', () => { - testUtil.mockEndpoints([{ - endpoint: '/mgmt/tm/util/unix-rm', - method: 'post', - request: { - command: 'run', - utilCmdArgs: '"/path/to/remove"' - }, - response: { - commandResult: 'some error message' - } - }], { - host: remoteHostName - }); - return assert.isFulfilled(deviceApiInst.removeFile('/path/to/remove')); - }); - }); - }); - - describe('LocalDeviceAPI', () => { - it('should set host to localhost', () => { - const inst = new ihealthUtil.LocalDeviceAPI(); - assert.strictEqual(inst.host, 'localhost'); - }); - }); - - describe('QkviewManager', () => { - const downloadFolder = 'downloadFolder'; - let initializeStub; - let qkmInst; - - beforeEach(() => { - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { downloadFolder }); - initializeStub = sinon.stub(ihealthUtil.QkviewManager.prototype, 'initialize'); - initializeStub.callsFake(function () { return this; }); - }); - - describe('constructor', () => { - it('should initialize without options', () => { - qkmInst = new ihealthUtil.QkviewManager(remoteHostName); - assert.strictEqual(qkmInst.downloadFolder, '', 'should set to empty string by default'); - assert.instanceOf(qkmInst.localDevice, ihealthUtil.LocalDeviceAPI); - assert.instanceOf(qkmInst.remoteDevice, ihealthUtil.DeviceAPI); - }); - - it('should initialize with options', () => { - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { - connection: { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }, - credentials: { - username: 'username', - passphrase: 'passphrase', - token: 'token' - }, - downloadFolder - }); - assert.strictEqual(qkmInst.downloadFolder, downloadFolder); - assert.instanceOf(qkmInst.localDevice, ihealthUtil.LocalDeviceAPI); - assert.instanceOf(qkmInst.remoteDevice, ihealthUtil.DeviceAPI); - assert.strictEqual(qkmInst.localDevice.host, 'localhost'); - assert.strictEqual(qkmInst.remoteDevice.host, remoteHostName); - assert.deepStrictEqual(qkmInst.remoteDevice.connection, { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }); - assert.deepStrictEqual(qkmInst.remoteDevice.credentials, { - username: 'username', - passphrase: 'passphrase', - token: 'token' - }); - }); - }); - - describe('.createQkview()', () => { - it('should call remote device to create qkview', () => { - const remoteQkviewCall = sinon.stub(qkmInst.remoteDevice, 'createQkview'); - remoteQkviewCall.resolves(); - return qkmInst.createQkview() - .then(() => { - assert.strictEqual(remoteQkviewCall.callCount, 1, 'should call just once'); - assert.isTrue(remoteQkviewCall.args[0][0].startsWith('qkview_telemetry_')); - }); - }); - }); - - describe('.downloadFile()', () => { - it('should download file from remote device and check MD5 sums', () => { - const remoteDownloadFile = sinon.stub(qkmInst.remoteDevice, 'downloadFile'); - remoteDownloadFile.callsFake((remote, local) => Promise.resolve(local)); - - const remoteMD5Sum = sinon.stub(qkmInst.remoteDevice, 'getMD5sum'); - remoteMD5Sum.resolves('MD5'); - - const localMD5Sum = sinon.stub(qkmInst.localDevice, 'getMD5sum'); - localMD5Sum.resolves('MD5'); - - return qkmInst.downloadFile('remotePath', 'localPath') - .then(() => { - assert.strictEqual(remoteDownloadFile.callCount, 1); - assert.strictEqual(remoteMD5Sum.callCount, 1); - assert.strictEqual(localMD5Sum.callCount, 1); - assert.strictEqual(remoteDownloadFile.args[0][0], 'remotePath'); - assert.strictEqual(remoteDownloadFile.args[0][1], 'localPath'); - assert.strictEqual(remoteMD5Sum.args[0][0], 'remotePath'); - assert.strictEqual(localMD5Sum.args[0][0], 'localPath'); - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - return qkmInst.downloadFile('remotePath', 'localPath'); - }) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - }); - }); - - it('should fail when MD5 doesn\'t match', () => { - const remoteDownloadFile = sinon.stub(qkmInst.remoteDevice, 'downloadFile'); - remoteDownloadFile.resolvesArg(1); - - const remoteMD5Sum = sinon.stub(qkmInst.remoteDevice, 'getMD5sum'); - remoteMD5Sum.resolves('MD5remote'); - - const localMD5Sum = sinon.stub(qkmInst.localDevice, 'getMD5sum'); - localMD5Sum.resolves('MD5local'); - - return assert.isRejected( - qkmInst.downloadFile('remotePath', 'localPath'), - /MD5 sum for downloaded Qkview file !== MD5 sum for Qkview on remote host/ - ); - }); - - it('should fail when not MD5 sums returned', () => { - const remoteDownloadFile = sinon.stub(qkmInst.remoteDevice, 'downloadFile'); - remoteDownloadFile.callsFake((remote, local) => Promise.resolve(local)); - - const remoteMD5Sum = sinon.stub(qkmInst.remoteDevice, 'getMD5sum'); - remoteMD5Sum.resolves(); - - const localMD5Sum = sinon.stub(qkmInst.localDevice, 'getMD5sum'); - localMD5Sum.resolves(); - - return assert.isRejected( - qkmInst.downloadFile('remotePath', 'localPath'), - /MD5 sum for downloaded Qkview file !== MD5 sum for Qkview on remote host/ - ); - }); - }); - - describe('.generateQkviewName()', () => { - it('should generate new name each time', () => { - const name1 = qkmInst.generateQkviewName(); - const name2 = qkmInst.generateQkviewName(); - assert.isTrue(name1.startsWith('qkview_telemetry_')); - assert.isTrue(name2.startsWith('qkview_telemetry_')); - assert.isTrue(name1.length > 0, 'should not be empty'); - assert.isTrue(name2.length > 0, 'should not be empty'); - assert.notStrictEqual(name1, name2, 'should generate unique name'); - }); - }); - - describe('.initialize()', () => { - beforeEach(() => { - initializeStub.restore(); - }); - - it('should initialize devices', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/authn/login', - code: 200, - method: 'post', - request: { - username: 'username', - password: 'passphrase', - loginProviderName: 'tmos' - }, - response: { - token: { - token: 'token' - } - } - }, - { - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: remoteHostName, - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: remoteHostName, - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }], - { - host: remoteHostName, - port: 443, - proto: 'https' - } - ); - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:01', - build: '0.0.1', - chassisSerialNumber: '00000000-0000-0000-000000000001', - halUuid: '00000000-0000-0000-0000-000000000001', - hostMac: '00:00:00:00:00:01', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000001', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }] - ); - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { - connection: { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - }, - downloadFolder - }); - return qkmInst.initialize() - .then((retInst) => { - assert.isTrue(retInst.localDevice !== retInst.remoteDevice); - assert.instanceOf(retInst.localDevice, ihealthUtil.LocalDeviceAPI); - assert.instanceOf(retInst.remoteDevice, ihealthUtil.DeviceAPI); - }); - }); - - it('should fail to initialize when downloadFolder not specified', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/authn/login', - code: 200, - method: 'post', - request: { - username: 'username', - password: 'passphrase', - loginProviderName: 'tmos' - }, - response: { - token: { - token: 'token' - } - } - }, - { - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: remoteHostName, - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: remoteHostName, - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }], - { - host: remoteHostName, - port: 443, - proto: 'https' - } - ); - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:01', - build: '0.0.1', - chassisSerialNumber: '00000000-0000-0000-000000000001', - halUuid: '00000000-0000-0000-0000-000000000001', - hostMac: '00:00:00:00:00:01', - hostname: 'localhost.localdomain', - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000001', - managementAddress: '192.168.1.10', - mcpDeviceName: '/Common/localhost.localdomain', - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8a', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }] - ); - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { - connection: { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - } - }); - return assert.isRejected(qkmInst.initialize(), /Should specify directory for downloads/); - }); - - it('should set remote device to local device when REST API returns same device info', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/authn/login', - code: 200, - method: 'post', - request: { - username: 'username', - password: 'passphrase', - loginProviderName: 'tmos' - }, - response: { - token: { - token: 'token' - } - } - }, - { - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: remoteHostName, - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: remoteHostName, - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }], - { - host: remoteHostName, - port: 443, - proto: 'https' - } - ); - testUtil.mockEndpoints( - [{ - endpoint: '/mgmt/shared/identified-devices/config/device-info', - method: 'get', - response: { - baseMac: '00:00:00:00:00:00', - build: '0.0.0', - chassisSerialNumber: '00000000-0000-0000-000000000000', - halUuid: '00000000-0000-0000-0000-000000000000', - hostMac: '00:00:00:00:00:00', - hostname: remoteHostName, - isClustered: false, - isVirtual: true, - machineId: '00000000-0000-0000-000000000000', - managementAddress: '192.168.1.10', - mcpDeviceName: remoteHostName, - physicalMemory: 7168, - platform: 'Z100', - product: 'BIG-IP', - trustDomainGuid: '3ed9b666-e28c-4958-9726fa163e25ef8', - version: '13.1.0', - generation: 0, - lastUpdateMicros: 0, - kind: 'shared:resolver:device-groups:deviceinfostate', - selfLink: 'https://localhost/mgmt/shared/identified-devices/config/device-info' - } - }] - ); - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { - connection: { - port: 443, - protocol: 'https', - allowSelfSignedCert: true - }, - credentials: { - username: 'username', - passphrase: 'passphrase' - } - }); - return qkmInst.initialize() - .then((retInst) => { - assert.isTrue(retInst.localDevice === retInst.remoteDevice); - assert.instanceOf(retInst.localDevice, ihealthUtil.LocalDeviceAPI); - assert.instanceOf(retInst.remoteDevice, ihealthUtil.LocalDeviceAPI); - }); - }); - }); - - describe('.process()', () => { - let getDeviceInfoStub; - beforeEach(() => { - sinon.stub(ihealthUtil.DeviceAPI.prototype, 'initialize').resolves({}); - getDeviceInfoStub = sinon.stub(ihealthUtil.DeviceAPI.prototype, 'getDeviceInfo'); - getDeviceInfoStub.callsFake(function () { - return { local: this instanceof ihealthUtil.LocalDeviceAPI }; - }); - initializeStub.restore(); - }); - - it('should create and download Qkview from remote host (and remove it from remote device)', () => { - sinon.stub(ihealthUtil.QkviewManager.prototype, 'createQkview').resolves('/remote/path/to/remote.qkview'); - sinon.stub(ihealthUtil.QkviewManager.prototype, 'downloadFile').resolvesArg(1); - const initializeSpy = sinon.spy(ihealthUtil.QkviewManager.prototype, 'initialize'); - - const removedFiles = []; - sinon.stub(ihealthUtil.DeviceAPI.prototype, 'removeFile').callsFake(function (fileToRemove) { - removedFiles.push({ - file: fileToRemove, - local: this instanceof ihealthUtil.LocalDeviceAPI - }); - }); - - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { downloadFolder }); - return qkmInst.process() - .then((localPath) => { - assert.strictEqual(localPath, path.join(downloadFolder, 'remote.qkview')); - assert.deepStrictEqual(removedFiles, [{ local: false, file: '/remote/path/to/remote.qkview' }]); - assert.strictEqual(initializeSpy.callCount, 1, 'should initialize only once'); - return qkmInst.process(); - }) - .then(() => { - assert.strictEqual(initializeSpy.callCount, 1, 'should initialize only once'); - }); - }); - - it('should cleanup when failed', () => { - sinon.stub(ihealthUtil.QkviewManager.prototype, 'createQkview').resolves('/remote/path/to/remote.qkview'); - sinon.stub(ihealthUtil.QkviewManager.prototype, 'downloadFile').rejects(new Error('downloadError')); - - const removedFiles = []; - sinon.stub(ihealthUtil.DeviceAPI.prototype, 'removeFile').callsFake(function (fileToRemove) { - removedFiles.push({ - file: fileToRemove, - local: this instanceof ihealthUtil.LocalDeviceAPI - }); - }); - - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { downloadFolder }); - return assert.isRejected(qkmInst.process(), /downloadError/) - .then(() => { - assert.deepStrictEqual(removedFiles, [ - { - local: false, - file: '/remote/path/to/remote.qkview' - }, - { - local: true, - file: path.join(downloadFolder, 'remote.qkview') - } - ]); - }); - }); - - it('should skip download when remote device is local device', () => { - sinon.stub(ihealthUtil.QkviewManager.prototype, 'createQkview').resolves('/remote/path/to/remote.qkview'); - sinon.stub(ihealthUtil.QkviewManager.prototype, 'downloadFile').rejects(new Error('downloadError')); - getDeviceInfoStub.reset(); - getDeviceInfoStub.resolves({ sameDevice: true }); - - const removedFiles = []; - sinon.stub(ihealthUtil.DeviceAPI.prototype, 'removeFile').callsFake(function (fileToRemove) { - removedFiles.push({ - file: fileToRemove, - local: this instanceof ihealthUtil.LocalDeviceAPI - }); - }); - - qkmInst = new ihealthUtil.QkviewManager(remoteHostName, { downloadFolder }); - return qkmInst.process() - .then((localPath) => { - assert.strictEqual(localPath, '/remote/path/to/remote.qkview'); - assert.isEmpty(removedFiles); - }); - }); - }); - }); - - describe('IHealthAPI', () => { - let ihealthAPI; - - beforeEach(() => { - ihealthAPI = new ihealthUtil.IHealthAPI({ - username: 'username', - passphrase: 'passphrase' - }); - - testUtil.mockEndpoints( - [{ - endpoint: '/auth/pub/sso/login/ihealth-api', - code: 200, - method: 'post', - request: { - user_id: 'username', - user_secret: 'passphrase' - }, - requestHeaders: { - 'Content-Type': 'application/json' - }, - responseHeaders: { - 'Set-Cookie': 'someCookie=someValue; Path=/; Domain=.f5.com' - } - }], - { - host: 'api.f5.com', - port: 443, - proto: 'https' - } - ); - }); - - describe('constructor', () => { - beforeEach(() => { - nock.cleanAll(); - }); - - it('should fail when no credentials provided', () => { - assert.throws( - () => new ihealthUtil.IHealthAPI({}), - /Username and passphrase are required!/ - ); - assert.throws( - () => new ihealthUtil.IHealthAPI({ username: 'username' }), - /Username and passphrase are required!/ - ); - assert.throws( - () => new ihealthUtil.IHealthAPI({ passphrase: 'passphrase' }), - /Username and passphrase are required!/ - ); - }); - }); - - describe('.authenticate()', () => { - const setupNockEndpoint = (endpointOpts) => { - testUtil.mockEndpoints( - [Object.assign({ - endpoint: '/auth/pub/sso/login/ihealth-api', - code: 200, - method: 'post', - request: { - user_id: 'username', - user_secret: 'passphrase' - }, - requestHeaders: { - 'Content-Type': 'application/json' - }, - responseHeaders: { - 'Set-Cookie': 'someCookie=someValue; Path=/; Domain=.f5.com' - } - }, endpointOpts || {})], - { - host: 'api.f5.com', - port: 443, - proto: 'https' - } - ); - }; - - beforeEach(() => { - nock.cleanAll(); - }); - - it('should authenticate to F5 iHealth Service', () => { - setupNockEndpoint(); - return ihealthAPI.authenticate(); - }); - - it('should fail to authenticate to F5 iHealth Service', () => { - setupNockEndpoint({ code: 400 }); - return assert.isRejected( - ihealthAPI.authenticate(), - /Bad status code/ - ); - }); - }); - - describe('.fetchQkviewDiagnostics()', () => { - const qkviewURI = 'https://ihealth-api.f5.com/qkview/myLovelyQkview'; - const setupNockEndpoint = (endpointOpts) => { - testUtil.mockEndpoints( - [Object.assign({ - endpoint: '/qkview/myLovelyQkview/diagnostics.json', - code: 200, - method: 'get', - requestHeaders: { - Accept: 'application/vnd.f5.ihealth.api.v1.0+json', - Cookie: 'someCookie=someValue' - }, - response: { - diagnostics: 'JSON' - } - }, endpointOpts || {})], - { - host: 'ihealth-api.f5.com', - port: 443, - proto: 'https' - } - ); - }; - - it('should be able to fetch diagnostics JSON', () => { - setupNockEndpoint(); - return assert.becomes( - ihealthAPI.authenticate() - .then(() => ihealthAPI.fetchQkviewDiagnostics(qkviewURI)), - { diagnostics: 'JSON' } - ); - }); - - it('should fail when unable to fetch diagnostics JSON', () => { - setupNockEndpoint({ code: 400 }); - return assert.isRejected( - ihealthAPI.authenticate() - .then(() => ihealthAPI.fetchQkviewDiagnostics(qkviewURI)), - /Bad status code/ - ); - }); - - it('should fail when response has no \'diagnostics\' key', () => { - setupNockEndpoint({ - response: { - anotherDiagnostics: 'JSON' - } - }); - return assert.isRejected( - ihealthAPI.authenticate() - .then(() => ihealthAPI.fetchQkviewDiagnostics(qkviewURI)), - /Missing 'diagnostics' in JSON response from F5 iHeath Service/ - ); - }); - - it('should fail when response is not parsed JSON object', () => { - setupNockEndpoint({ - response: 'someString' - }); - return assert.isRejected( - ihealthAPI.authenticate() - .then(() => ihealthAPI.fetchQkviewDiagnostics(qkviewURI)), - /Invalid JSON response from F5 iHeath Service/ - ); - }); - }); - - describe('.isQkviewReportReady()', () => { - const setupNockEndpoint = (endpointOpts) => { - testUtil.mockEndpoints( - [Object.assign({ - endpoint: '/qkview/myLovelyQkview', - code: 200, - method: 'get', - requestHeaders: { - Accept: 'application/vnd.f5.ihealth.api.v1.0', - Cookie: 'someCookie=someValue' - } - }, endpointOpts || {})], - { - host: 'ihealth-api.f5.com', - port: 443, - proto: 'https' - } - ); - }; - - it('should return true when Qkview report is ready', () => { - setupNockEndpoint(); - return assert.becomes( - ihealthAPI.authenticate() - .then(() => ihealthAPI.isQkviewReportReady('https://ihealth-api.f5.com/qkview/myLovelyQkview')), - true - ); - }); - - it('should return false when Qkview report is not ready', () => { - setupNockEndpoint({ code: 202 }); - return assert.becomes( - ihealthAPI.authenticate() - .then(() => ihealthAPI.isQkviewReportReady('https://ihealth-api.f5.com/qkview/myLovelyQkview')), - false - ); - }); - }); - - describe('.uploadQkview()', () => { - const createReadStreamOrigin = fs.createReadStream; - const qkviewFileStreamFile = 'qkviewFileStreamFile'; - const setupNockEndpoint = (endpointOpts) => { - testUtil.mockEndpoints( - [Object.assign({ - endpoint: '/qkview-analyzer/api/qkviews', - code: 200, - method: 'post', - requestHeaders: { - Accept: 'application/vnd.f5.ihealth.api.v1.0+json', - Cookie: 'someCookie=someValue' - }, - response: { - result: 'OK', - location: 'qkviewLocationURI' - } - }, endpointOpts || {})], - { - host: 'ihealth-api.f5.com', - port: 443, - proto: 'https' - } - ); - }; - - beforeEach(() => { - sinon.stub(fs, 'createReadStream').callsFake(function () { - if (arguments[0] === qkviewFileStreamFile) { - return qkviewFileStreamFile; - } - return createReadStreamOrigin.apply(fs, arguments); - }); - }); - - it('should upload Qkview file', () => { - setupNockEndpoint(); - return assert.becomes( - ihealthAPI.authenticate() - .then(() => ihealthAPI.uploadQkview(qkviewFileStreamFile)), - 'qkviewLocationURI' - ); - }); - - it('should fail when unable to parse response', () => { - setupNockEndpoint({ response: '[some data' }); - return assert.isRejected( - ihealthAPI.authenticate() - .then(() => ihealthAPI.uploadQkview(qkviewFileStreamFile)), - /Unable to upload Qkview to F5 iHealth server.*unable to parse response body/ - ); - }); - - it('should fail when received invalid response', () => { - setupNockEndpoint({ - response: { - result: 'OK' - } - }); - return assert.isRejected( - ihealthAPI.authenticate() - .then(() => ihealthAPI.uploadQkview(qkviewFileStreamFile)), - /Unable to upload Qkview to F5 iHealth server.*unable to find "location" in response body/ - ); - }); - }); - - describe('proxy', () => { - beforeEach(() => { - nock.cleanAll(); - }); - - it('should work with proxy', () => { - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'sendRequest').callsFake((opts) => { - assert.deepStrictEqual(opts.proxy, { - host: 'proxyHost', - port: 443, - protocol: 'https', - username: 'username', - passphrase: 'passphrase' - }); - }); - ihealthAPI = new ihealthUtil.IHealthAPI({ - username: 'username', - passphrase: 'passphrase' - }, { - proxy: { - credentials: { - username: 'username', - passphrase: 'passphrase' - }, - connection: { - host: 'proxyHost', - port: 443, - protocol: 'https' - } - } - }); - return ihealthAPI.authenticate(); - }); - - it('should return default value for strictSSL', () => { - assert.isTrue(ihealthAPI.getStrictSSL()); - }); - - it('should return default value for strictSSL', () => { - ihealthAPI = new ihealthUtil.IHealthAPI({ - username: 'username', - passphrase: 'passphrase' - }, { - proxy: { - credentials: { - username: 'username', - passphrase: 'passphrase' - }, - connection: { - host: 'proxyHost', - port: 443, - protocol: 'https', - allowSelfSignedCert: true - } - } - }); - assert.isFalse(ihealthAPI.getStrictSSL()); - }); - }); - }); - - describe('IHealthManager', () => { - let ihealthMgr; - let initializeStub; - - beforeEach(() => { - ihealthMgr = new ihealthUtil.IHealthManager({ - username: 'username', - passphrase: 'passphrase' - }); - initializeStub = sinon.stub(ihealthUtil.IHealthManager.prototype, 'initialize'); - initializeStub.callsFake(function () { return this; }); - }); - - describe('constructor', () => { - it('should set default options', () => { - assert.strictEqual(ihealthMgr.qkviewFile, undefined); - assert.strictEqual(ihealthMgr.qkviewURI, undefined); - assert.strictEqual(ihealthMgr.api.username, 'username'); - assert.strictEqual(ihealthMgr.api.passphrase, 'passphrase'); - }); - - it('should set available options', () => { - ihealthMgr = new ihealthUtil.IHealthManager({ - username: 'username', - passphrase: 'passphrase' - }, { - qkviewFile: 'qkviewFile', - qkviewURI: 'qkviewURI' - }); - assert.strictEqual(ihealthMgr.qkviewFile, 'qkviewFile'); - assert.strictEqual(ihealthMgr.qkviewURI, 'qkviewURI'); - assert.strictEqual(ihealthMgr.api.username, 'username'); - assert.strictEqual(ihealthMgr.api.passphrase, 'passphrase'); - }); - }); - - describe('.fetchQkviewDiagnostics()', () => { - it('should fail to fetch diagnostics when no qkviewURI specified', () => assert.isRejected( - ihealthMgr.fetchQkviewDiagnostics(), - /Qkview URI not specified/ - )); - - it('should fetch diagnostics', () => { - ihealthMgr.qkviewURI = 'qkviewURI'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'fetchQkviewDiagnostics') - .callsFake((qkURI) => Promise.resolve() - .then(() => { - assert.strictEqual(qkURI, 'qkviewURI'); - return { diagnostics: 'JSON' }; - })); - return assert.becomes( - ihealthMgr.fetchQkviewDiagnostics(), - { diagnostics: 'JSON' } - ) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - return ihealthMgr.fetchQkviewDiagnostics(); - }) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - }); - }); - - it('should fetch diagnostics from URI passed as arg', () => { - ihealthMgr.qkviewURI = 'qkviewURI'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'fetchQkviewDiagnostics') - .callsFake((qkURI) => Promise.resolve() - .then(() => { - assert.strictEqual(qkURI, 'qkviewURI_arg'); - return { diagnostics: 'JSON' }; - })); - return assert.becomes( - ihealthMgr.fetchQkviewDiagnostics('qkviewURI_arg'), - { diagnostics: 'JSON' } - ); - }); - }); - - describe('.initialize()', () => { - beforeEach(() => { - initializeStub.restore(); - }); - - it('should authenticate to F5 iHealth Service', () => { - testUtil.mockEndpoints( - [{ - endpoint: '/auth/pub/sso/login/ihealth-api', - code: 200, - method: 'post', - request: { - user_id: 'username', - user_secret: 'passphrase' - }, - requestHeaders: { - 'Content-Type': 'application/json' - }, - responseHeaders: { - 'Set-Cookie': 'someCookie=someValue; Path=/; Domain=.f5.com' - } - }], - { - host: 'api.f5.com', - port: 443, - proto: 'https' - } - ); - return ihealthMgr.initialize() - .then((retInst) => { - assert.isTrue(ihealthMgr === retInst); - assert.instanceOf(retInst, ihealthUtil.IHealthManager); - }); - }); - }); - - describe('.isQkviewReportReady()', () => { - it('should fail to check report status when no qkviewURI specified', () => assert.isRejected( - ihealthMgr.isQkviewReportReady(), - /Qkview URI not specified/ - )); - - it('should be able to check report status', () => { - ihealthMgr.qkviewURI = 'qkviewURI'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'isQkviewReportReady') - .callsFake((qkURI) => Promise.resolve() - .then(() => { - assert.strictEqual(qkURI, 'qkviewURI'); - return true; - })); - return assert.becomes(ihealthMgr.isQkviewReportReady(), true) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - return ihealthMgr.isQkviewReportReady(); - }) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - }); - }); - - it('should be able to check report status using URI passed as arg', () => { - ihealthMgr.qkviewURI = 'qkviewURI'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'isQkviewReportReady') - .callsFake((qkURI) => Promise.resolve() - .then(() => { - assert.strictEqual(qkURI, 'qkviewURI_arg'); - return true; - })); - return assert.becomes(ihealthMgr.isQkviewReportReady('qkviewURI_arg'), true); - }); - }); - - describe('.uploadQkview()', () => { - it('should fail to upload Qkview when file not specified', () => assert.isRejected( - ihealthMgr.uploadQkview(), - /Path to Qkview file not specified/ - )); - - it('should be able to upload Qkview', () => { - ihealthMgr.qkviewFile = 'qkviewFile'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'uploadQkview') - .callsFake((qkFile) => Promise.resolve() - .then(() => { - assert.strictEqual(qkFile, 'qkviewFile'); - return 'qkviewURIFromIHealth'; - })); - return assert.becomes(ihealthMgr.uploadQkview(), 'qkviewURIFromIHealth') - .then(() => { - assert.strictEqual(ihealthMgr.qkviewURI, 'qkviewURIFromIHealth'); - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - return ihealthMgr.uploadQkview(); - }) - .then(() => { - assert.strictEqual(initializeStub.callCount, 1, 'should initialize only once'); - }); - }); - - it('should be able to upload Qkview file passed as arg', () => { - ihealthMgr.qkviewFile = 'qkviewFile'; - sinon.stub(ihealthUtil.IHealthAPI.prototype, 'uploadQkview') - .callsFake((qkFile) => Promise.resolve() - .then(() => { - assert.strictEqual(qkFile, 'qkviewFile_arg'); - return 'qkviewURIFromIHealth'; - })); - return assert.becomes(ihealthMgr.uploadQkview('qkviewFile_arg'), 'qkviewURIFromIHealth') - .then(() => { - assert.strictEqual(ihealthMgr.qkviewURI, 'qkviewURIFromIHealth'); - }); - }); - }); - }); -}); diff --git a/test/unit/utils/miscTests.js b/test/unit/utils/miscTests.js index 6aadaad4..74fed93f 100644 --- a/test/unit/utils/miscTests.js +++ b/test/unit/utils/miscTests.js @@ -1975,45 +1975,41 @@ describe('Misc Util', () => { }); }); - describe('.proxyForNodeCallbackFuncs', () => { - it('should wrap origin function into promise-based function', () => { - const successFunc = (a, b, cb) => { - assert.deepStrictEqual(a, 10, 'should pass expected arg'); - assert.deepStrictEqual(b, 20, 'should pass expected arg'); - assert.isFunction(cb, 'should pass expected arg'); - cb(null, a + b); - }; - const promisified = util.proxyForNodeCallbackFuncs({ successFunc }, 'successFunc'); - return assert.becomes(promisified(10, 20), [30], 'should resolve with expected value'); - }); - - it('should reject when callback received error as first arg', () => { - const funcWithError = (a, b, cb) => { - assert.deepStrictEqual(a, 10, 'should pass expected arg'); - assert.deepStrictEqual(b, 20, 'should pass expected arg'); - assert.isFunction(cb, 'should pass expected arg'); - cb(new Error('expected error'), a + b); - }; - const promisified = util.proxyForNodeCallbackFuncs({ funcWithError }, 'funcWithError'); - return assert.isRejected(promisified(10, 20), /expected error/, 'should reject on error'); - }); - }); - describe('.onApplicationExit', () => { afterEach(() => { sinon.restore(); }); it('should register callback', () => { - const stub = sinon.stub(process, 'on'); - stub.callsFake(); - - util.onApplicationExit(() => {}); - assert.sameDeepMembers( - stub.args.map((args) => args[0]), - ['exit', 'SIGINT', 'SIGTERM', 'SIGHUP'], - 'should register callback' - ); + const listeners = []; + const getIdx = (evt, cb) => listeners.findIndex(([e, c]) => evt === e && cb === c); + + sinon.stub(process, 'on').callsFake((event, cb) => { + assert.deepStrictEqual(getIdx(event, cb), -1, 'should not register twice'); + listeners.push([event, cb]); + }); + sinon.stub(process, 'removeListener').callsFake((event, cb) => { + const idx = getIdx(event, cb); + assert.isAbove(idx, -1, 'should be registered'); + listeners.splice(idx, 1); + }); + sinon.stub(process, 'emit').callsFake((event, ...args) => { + listeners.forEach(([e, cb]) => { + if (e === event) { + cb(...args); + } + }); + }); + + const cbSpy = sinon.spy(); + const off = util.onApplicationExit(cbSpy); + ['exit', 'SIGINT', 'SIGTERM', 'SIGHUP'].forEach((evt) => process.emit(evt)); + + assert.deepStrictEqual(cbSpy.callCount, 4); + + off(); + ['exit', 'SIGINT', 'SIGTERM', 'SIGHUP'].forEach((evt) => process.emit(evt)); + assert.deepStrictEqual(cbSpy.callCount, 4); }); }); diff --git a/test/unit/utils/promiseTests.js b/test/unit/utils/promiseTests.js index e648cbfc..19ab4828 100644 --- a/test/unit/utils/promiseTests.js +++ b/test/unit/utils/promiseTests.js @@ -23,6 +23,7 @@ const sinon = require('sinon'); const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); const promiseUtil = sourceCode('src/lib/utils/promise'); @@ -563,4 +564,22 @@ describe('Promise Util', () => { }); }); }); + + describe('.withResolvers()', () => { + it('should resolve promise', async () => { + const resolvers = promiseUtil.withResolvers(); + testUtil.sleep(100) + .then(() => resolvers.resolve('test')); + + await assert.becomes(resolvers.promise, 'test'); + }); + + it('should reject promise', async () => { + const resolvers = promiseUtil.withResolvers(); + testUtil.sleep(100) + .then(() => resolvers.reject(new Error('expected test error'))); + + await assert.isRejected(resolvers.promise, /expected test error/); + }); + }); }); diff --git a/test/unit/utils/requestsTests.js b/test/unit/utils/requestsTests.js index 4a35cf24..b1035031 100644 --- a/test/unit/utils/requestsTests.js +++ b/test/unit/utils/requestsTests.js @@ -19,12 +19,14 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); -const sinon = require('sinon'); +const http = require('http'); const nock = require('nock'); const request = require('request'); +const sinon = require('sinon'); const assert = require('../shared/assert'); const sourceCode = require('../shared/sourceCode'); +const stubs = require('../shared/stubs'); const testUtil = require('../shared/util'); const requestsUtil = sourceCode('src/lib/utils/requests'); @@ -32,18 +34,32 @@ const requestsUtil = sourceCode('src/lib/utils/requests'); moduleCache.remember(); describe('Requests Util', () => { + let coreStub; + before(() => { moduleCache.restore(); }); describe('.makeRequest()', () => { + beforeEach(() => { + coreStub = stubs.default.coreStub({ + logger: true, + utilMisc: true + }, { + logger: { + setToVerbose: true, + ignoreLevelChange: false + } + }); + }); + afterEach(() => { - testUtil.checkNockActiveMocks(nock); + testUtil.checkNockActiveMocks(); + testUtil.nockCleanup(); sinon.restore(); - nock.cleanAll(); }); - it('should make request with non-defaults', () => { + it('should make request with non-defaults', async () => { nock('https://example.com:443', { reqheaders: { 'User-Agent': /f5-telemetry/, @@ -51,6 +67,7 @@ describe('Requests Util', () => { } }) .post('/') + .times(2) .reply(200, { key: 'value' }); const originGet = request.get; @@ -60,6 +77,7 @@ describe('Requests Util', () => { }); const opts = { + agent: new http.Agent(), port: 443, protocol: 'https', method: 'POST', @@ -68,10 +86,37 @@ describe('Requests Util', () => { }, allowSelfSignedCert: true }; - return assert.becomes( + await assert.becomes( + requestsUtil.makeRequest('example.com', testUtil.deepCopy(opts)), + { key: 'value' } + ); + + const messages = coreStub.logger.messages.verbose + .filter((msg) => msg.includes('reqID')) + .map((msg) => JSON.parse(msg.slice(msg.indexOf('{')))); + + assert.lengthOf(messages, 2); + assert.deepStrictEqual( + messages[0].reqID, + messages[1].reqID + ); + assert.isTrue(messages[0].options.agent); + assert.isAbove(messages[0].timestamp, 0); + assert.isAbove(messages[1].duration, 0); + + coreStub.logger.logger.setLogLevel('info'); + coreStub.logger.removeAllMessages(); + + assert.isEmpty(coreStub.logger.messages.all); + + await assert.becomes( requestsUtil.makeRequest('example.com', opts), { key: 'value' } ); + + assert.isEmpty(coreStub.logger.messages.verbose + .filter((msg) => msg.includes('reqID')) + .map((msg) => JSON.parse(msg.slice(msg.indexOf('{'))))); }); it('should make request with defaults (response code 200)', () => { @@ -134,7 +179,7 @@ describe('Requests Util', () => { return assert.isRejected( requestsUtil.makeRequest('example.com'), - /HTTP error:.*error message.*/ + /HTTP Error: error message/ ); }); diff --git a/test/unit/utils/serviceTests.js b/test/unit/utils/serviceTests.js index af84619a..707f5bc0 100644 --- a/test/unit/utils/serviceTests.js +++ b/test/unit/utils/serviceTests.js @@ -38,9 +38,7 @@ describe('Service', () => { }); beforeEach(() => { - coreStub = stubs.default.coreStub({ - logger: true - }); + coreStub = stubs.default.coreStub({ logger: true }); }); function shouldUseDebugOnly() { @@ -84,6 +82,12 @@ describe('Service', () => { afterEach(() => sinon.restore()); + it('should use parent logger', () => { + service = new Service(coreStub.logger.logger.getChild('parentLogger')); + service.logger.info('test'); + shouldLogMsg('info', /parentLogger\.Service.*test/); + }); + it('should return correct statuses for service', () => { checkState(service, 'stopped'); }); @@ -172,17 +176,40 @@ describe('Service', () => { shouldLogMsg(/destroyed\./); })); - it('should not be able to start/stop/restart destroyed service', () => service.destroy() + it('should be able to start/restart destroyed service', () => service.destroy() .then((retVal) => { assert.isTrue(retVal, 'should return true on attempt to destroy service'); checkState(service, 'destroyed'); shouldUseDebugOnly(); shouldLogMsg(/termination requested/); shouldLogMsg(/destroyed\./); + return service.start(); + }) + .then((retVal) => { + assert.isTrue(retVal, 'should return true on attempt to start service'); + checkState(service, 'running'); + shouldUseDebugOnly(); + shouldLogMsg(/running\./); + coreStub.logger.removeAllMessages(); + return service.destroy(); + }) + .then((retVal) => { + assert.isTrue(retVal, 'should return true on attempt to destroy service'); + checkState(service, 'destroyed'); + shouldUseDebugOnly(); + shouldLogMsg(/termination requested/); + shouldLogMsg(/destroyed\./); + return service.restart(); + }) + .then((retVal) => { + assert.isTrue(retVal, 'should return true on attempt to restart service'); + checkState(service, 'running'); + shouldUseDebugOnly(); + shouldLogMsg(/running\./); + coreStub.logger.removeAllMessages(); + return service.destroy(); }) - .then(() => assert.becomes(service.start(), false, 'should not be able to start destroyed service')) .then(() => assert.becomes(service.stop(), false, 'should not be able to stop destroyed service')) - .then(() => assert.becomes(service.restart(), false, 'should not be able to restart destroyed service')) .then(() => assert.becomes(service.destroy(), false, 'should not be able to destroy destroyed service'))); it('should be able to destroy running service', () => service.start() @@ -204,20 +231,27 @@ describe('Service', () => { }); describe('Custom Service', () => { + let onStartInfos; + let onStopInfos; + let onErrorCb; + let service; + class CustomService extends Service { - _onStart(onError) { + _onStart(onError, info) { + onStartInfos.push(info); return this._onStartCb(onError); } - _onStop(restart) { - return this._onStopCb(restart); + _onStop(info) { + onStopInfos.push(info); + return this._onStopCb(info); } } - let onErrorCb; - let service; - beforeEach(() => { + onStartInfos = []; + onStopInfos = []; + service = new CustomService(); service._onStartCb = (onError) => { service.logger.debug('running-msg...'); @@ -234,6 +268,7 @@ describe('Service', () => { service._onStartCb = () => { throw new Error('onStartError'); }; return assert.isRejected(service.start(), /onStartError/) .then(() => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); checkState(service, 'stopped'); shouldLogMsg(/stopped due error[\S\s]*onStartError/gm); }); @@ -243,43 +278,88 @@ describe('Service', () => { service._onStartCb = () => Promise.reject(new Error('onStartError')); return assert.isRejected(service.start(), /onStartError/) .then(() => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); checkState(service, 'stopped'); shouldLogMsg(/stopped due error[\S\s]*onStartError/gm); }); }); - it('should resolve on attempt to restart non-active service even when not able to stop (sync)', () => { - service._onStopCb = (restart) => { - assert.isTrue(restart, 'should be set to true'); - throw new Error('onStopError'); - }; + it('should not call stop callback on attempt to restart non-active service (sync)', () => { + service._onStopCb = sinon.spy(); return service.restart() .then((retVal) => { + assert.isEmpty(onStopInfos); + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); + assert.deepStrictEqual(service._onStopCb.callCount, 0); assert.isTrue(retVal, 'should return true on attempt to restart service'); checkState(service, 'running'); - shouldLogMsg(/caught error on attempt to stop[\S\s]*onStopError/gm); }); }); - it('should resolve on attempt to restart non-active service even when not able to stop (async)', () => { - service._onStopCb = (restart) => { - assert.isTrue(restart, 'should be set to true'); - return Promise.reject(new Error('onStopError')); - }; - return service.restart() - .then((retVal) => { - assert.isTrue(retVal, 'should return true on attempt to restart service'); - checkState(service, 'running'); - shouldLogMsg(/caught error on attempt to stop[\S\s]*onStopError/gm); - }); - }); + it('should correcrly set coldStart, restart and destroy flags', () => service.start() + .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); + assert.isTrue(retVal, 'should return true on attempt to start service'); + checkState(service, 'running'); + + return service.destroy(); + }) + .then((retVal) => { + assert.deepStrictEqual(onStopInfos, [{ destroy: true, restart: false }]); + assert.isTrue(retVal, 'should return true on attempt to destroy service'); + checkState(service, 'destroyed'); + + onStartInfos = []; + onStopInfos = []; + + return service.start(); + }) + .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); + assert.isTrue(retVal, 'should return true on attempt to start service'); + checkState(service, 'running'); + + return service.destroy(); + }) + .then((retVal) => { + assert.deepStrictEqual(onStopInfos, [{ destroy: true, restart: false }]); + assert.isTrue(retVal, 'should return true on attempt to destroy service'); + checkState(service, 'destroyed'); + + onStartInfos = []; + onStopInfos = []; + + return service.restart(); + }) + .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); + assert.deepStrictEqual(onStopInfos, []); + assert.isTrue(retVal, 'should return true on attempt to restart service'); + checkState(service, 'running'); + + return service.restart() + .then(() => service.restart()); + }) + .then(() => { + assert.deepStrictEqual(onStartInfos, [ + { coldStart: true, restart: false }, + { coldStart: false, restart: true }, + { coldStart: false, restart: true } + ]); + assert.deepStrictEqual(onStopInfos, [ + { destroy: false, restart: true }, + { destroy: false, restart: true } + ]); + checkState(service, 'running'); + })); it('should reject when not able to stop (sync)', () => service.start() .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); assert.isTrue(retVal, 'should return true on attempt to start service'); checkState(service, 'running'); - service._onStopCb = (restart) => { - assert.isFalse(restart, 'should be set to false'); + service._onStopCb = (info) => { + assert.isFalse(info.restart, 'should be set to false'); throw new Error('onStopError'); }; return assert.isRejected(service.stop(), /onStopError/); @@ -291,10 +371,11 @@ describe('Service', () => { it('should reject when not able to stop (async)', () => service.start() .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); assert.isTrue(retVal, 'should return true on attempt to start service'); checkState(service, 'running'); - service._onStopCb = (restart) => { - assert.isFalse(restart, 'should be set to false'); + service._onStopCb = (info) => { + assert.isFalse(info.restart, 'should be set to false'); return Promise.reject(new Error('onStopError')); }; return assert.isRejected(service.stop(), /onStopError/); @@ -306,6 +387,7 @@ describe('Service', () => { it('should ignore multiple errors when service is running and do restart', () => service.start() .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); assert.isTrue(retVal, 'should return true on attempt to start service'); checkState(service, 'running'); service.getRestartOptions = () => ({ delay: 10 }); @@ -314,9 +396,11 @@ describe('Service', () => { onErrorCb(new Error('onRunningError1')); onErrorCb(new Error('onRunningError2')); + onStartInfos = []; return testUtil.sleep(50); }) .then(() => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: false, restart: true }]); checkState(service, 'running'); shouldLogMsg(/restarting\.\.\./); shouldLogMsg('error', /restart requested due error[\S\s]*onRunningError1/gm); @@ -340,6 +424,7 @@ describe('Service', () => { }, 1) ]) .then(() => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); shouldNotLogMsg(/running/); checkState(service, 'destroyed'); shouldLogMsg(/termination requested/); @@ -356,6 +441,7 @@ describe('Service', () => { it('should destroy stopping service', () => service.start() .then((retVal) => { + assert.deepStrictEqual(onStartInfos, [{ coldStart: true, restart: false }]); assert.isTrue(retVal, 'should return true on attempt to start service'); checkState(service, 'running'); @@ -457,6 +543,10 @@ describe('Service', () => { }); }) .then(() => { + assert.deepStrictEqual(onStopInfos.slice(-2), [ + { destroy: false, restart: true }, + { destroy: true, restart: false } + ]); checkState(service, 'destroyed'); shouldLogMsg(/stop requested.*restarting/); })); @@ -521,8 +611,8 @@ describe('Service', () => { checkState(service, 'running'); service._onStart = (onError) => { - const originCancel = service._fatalErrorHandler.cancel; - service._fatalErrorHandler.cancel = () => { + const originCancel = service.__fatalErrorHandler.cancel; + service.__fatalErrorHandler.cancel = () => { // call origin to avoid infinite loop originCancel(); throw new Error('onUncaughtError'); diff --git a/test/unit/utils/systemStatsTests.js b/test/unit/utils/systemStatsTests.js deleted file mode 100644 index 2bbb4455..00000000 --- a/test/unit/utils/systemStatsTests.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright 2024 F5, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* eslint-disable import/order */ -const moduleCache = require('../shared/restoreCache')(); - -const assert = require('../shared/assert'); -const systemStatsUtilTestsData = require('../data/systemStatsUtilTestsData'); -const sourceCode = require('../shared/sourceCode'); -const testUtil = require('../shared/util'); - -const systemStatsUtil = sourceCode('src/lib/utils/systemStats'); - -moduleCache.remember(); - -describe('System Stats Utils', () => { - before(() => { - moduleCache.restore(); - }); - - describe('.renderProperty()', () => { - systemStatsUtilTestsData.renderProperty.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => { - const contextStateValidator = testUtil.getSpoiledDataValidator(testConf.contextData); - const propertyCopy = testUtil.deepCopy(testConf.propertyData); - - const promise = new Promise((resolve, reject) => { - try { - resolve(systemStatsUtil.renderProperty(testConf.contextData, propertyCopy)); - } catch (err) { - reject(err); - } - }); - if (testConf.errorMessage) { - return assert.isRejected(promise, testConf.errorMessage); - } - return promise.then((result) => { - assert.deepStrictEqual(result, testConf.expectedData, 'should match expected data'); - assert.deepStrictEqual(result, propertyCopy, 'should modify property in place'); - contextStateValidator(); - }); - }); - }); - }); - - describe('.splitKey()', () => { - systemStatsUtilTestsData.splitKey.forEach((testConf) => { - testUtil.getCallableIt(testConf)(testConf.name, () => assert.deepStrictEqual( - systemStatsUtil.splitKey(testConf.key), - testConf.expected - )); - }); - }); -}); diff --git a/test/unit/utils/taskQueueTests.js b/test/unit/utils/taskQueueTests.js new file mode 100644 index 00000000..3750db67 --- /dev/null +++ b/test/unit/utils/taskQueueTests.js @@ -0,0 +1,510 @@ +/** + * Copyright 2024 F5, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* eslint-disable import/order, no-loop-func, no-restricted-syntax */ +const moduleCache = require('../shared/restoreCache')(); + +const sinon = require('sinon'); +const assert = require('../shared/assert'); +const stubs = require('../shared/stubs'); +const sourceCode = require('../shared/sourceCode'); +const testUtil = require('../shared/util'); + +const logger = sourceCode('src/lib/logger'); +const promiseUtil = sourceCode('src/lib/utils/promise'); +const TaskQueue = sourceCode('src/lib/utils/taskQueue'); +const constants = sourceCode('src/lib/constants'); + +moduleCache.remember(); + +describe('Task Queue', () => { + const HIGH_PRIO = constants.TASK.HIGH_PRIORITY; + const LOW_PRIO = constants.TASK.LOW_PRIORITY; + let coreStub; + + before(() => { + moduleCache.restore(); + }); + + beforeEach(() => { + coreStub = stubs.default.coreStub({ logger: true }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('constructor()', () => { + const testTaskNoOp = () => {}; + + it('should assign default options', () => { + const taskQDef = new TaskQueue(testTaskNoOp); + assert.strictEqual(taskQDef.concurrency, 1, 'should have 1 worker by default'); + assert.strictEqual(taskQDef.maxSize, Number.MAX_SAFE_INTEGER, 'should have no max size'); + }); + + it('should apply custom options', () => { + const customOpts = { + concurrency: 3, + usePriority: true, + maxSize: 5, + logger + }; + const taskQCustom = new TaskQueue(testTaskNoOp, customOpts); + assert.strictEqual(taskQCustom.concurrency, customOpts.concurrency); + assert.strictEqual(taskQCustom.maxSize, customOpts.maxSize); + }); + + it('should assign a unique name to each instance', () => { + const queues = [ + new TaskQueue(testTaskNoOp), + new TaskQueue(testTaskNoOp, { concurrency: 2 }), + new TaskQueue(testTaskNoOp), + new TaskQueue(testTaskNoOp, { maxSize: 10 }), + new TaskQueue(testTaskNoOp) + ]; + const uniqueNames = Array.from(new Set(queues.map((q) => q.name))); + assert.equal(uniqueNames.length, queues.length); + }); + + it('should immediately be ready upon instantiation', () => { + const taskQDef = new TaskQueue(testTaskNoOp); + assert.isTrue(taskQDef.isIdle()); + }); + + it('should throw error on invalid argunents', () => { + assert.throws(() => new TaskQueue(null), 'fn should be a function'); + assert.throws(() => new TaskQueue(() => {}, { + concurrency: null + }), 'concurrency should be a safe number'); + assert.throws(() => new TaskQueue(() => {}, { + concurrency: 0 + }), 'concurrency should be >= 1, got 0'); + assert.throws(() => new TaskQueue(() => {}, { + logger: null + }), 'logger should be an instance of Logger'); + assert.throws(() => new TaskQueue(() => {}, { + maxSize: 0 + }), 'maxSize should be >= 1, got 0'); + assert.throws(() => new TaskQueue(() => {}, { + maxSize: Number.MAX_VALUE + }), 'maxSize should be a safe number'); + assert.throws(() => new TaskQueue(() => {}, { + maxSize: Number.MAX_SAFE_INTEGER + 1 + }), 'maxSize should be a safe number'); + assert.throws(() => new TaskQueue(() => {}, { + name: 10 + }), 'name should be a string'); + assert.throws(() => new TaskQueue(() => {}, { + name: '' + }), 'name should be a non-empty collection'); + assert.throws(() => new TaskQueue(() => {}, { + usePriority: '' + }), 'usePriority should be a boolean'); + }); + }); + + describe('operations', () => { + let taskQ; + let taskPrioQ; + let taskMultiQ; + let processed = []; + + const errorMock = new Error('Simulated error for task run'); + const taskSucceeds = { name: 'successful_task' }; + const taskFails = { + name: 'failed_task', + priority: HIGH_PRIO, + errorOnRun: true + }; + + function processTask(task, cb, info) { + const taskRet = { + name: task.name, + taskID: info.taskID, + workerID: info.workerID, + priority: task.priority, + errored: task.errorOnRun || false + }; + try { + if (task.errorOnRun) { + throw errorMock; + } else { + task.cb(null, taskRet); + } + } catch (err) { + task.cb(err, taskRet); + } finally { + cb(); + } + } + + function addTaskToQueue(task, q) { + q = q || taskQ; + task.priority = task.priority || LOW_PRIO; + return new Promise((resolve, reject) => { + task.cb = (err, ret) => { + processed.push(ret); + if (err) { + reject(err); + } else { + resolve(ret); + } + }; + if (!q.push(task)) { + resolve(); + } + }); + } + + function addTaskToPrioQueue(task) { + return addTaskToQueue(task, taskPrioQ); + } + + function addTaskToMultiQueue(task) { + return addTaskToQueue(task, taskMultiQ); + } + + function processTasks(taskList, keepTaskIDs, keepWorkerIDs) { + return promiseUtil.allSettled(taskList) + .then(() => { + const parsedResults = processed.map((t) => { + if (!keepTaskIDs) { delete t.taskID; } + if (!keepWorkerIDs) { delete t.workerID; } + return t; + }); + return Promise.resolve(parsedResults); + }); + } + + beforeEach(() => { + taskQ = new TaskQueue(processTask); + taskPrioQ = new TaskQueue(processTask, { usePriority: true }); + taskMultiQ = new TaskQueue(processTask, { concurrency: 2 }); + }); + + afterEach(() => { + taskQ = undefined; + taskPrioQ = undefined; + taskMultiQ = undefined; + processed = []; + }); + + it('should generate unique id for each task', async () => { + const tasks = [ + addTaskToQueue({ name: 'task1' }), + addTaskToQueue({ name: 'task2' }), + addTaskToQueue({ name: 'task3' }) + ]; + await processTasks(tasks, true); + + const ids = Array.from(new Set(processed.map((t) => t.taskID))); + assert.equal(ids.length, tasks.length, 'should not have duplicate ids'); + }); + + it('should process a task added to the queue', async () => { + const taskResult = await addTaskToQueue({ + name: 'simple task', + priority: HIGH_PRIO + }); + assert.exists(taskResult.taskID); + assert.strictEqual(taskResult.priority, HIGH_PRIO); + assert.strictEqual(taskResult.name, 'simple task'); + }); + + it('should process multiple tasks with both success and failure', async () => { + const tasks = [ + addTaskToQueue(taskSucceeds), + addTaskToQueue(taskFails), + addTaskToQueue({ name: 'someOtherTask', priority: HIGH_PRIO }) + ]; + + await assert.becomes(processTasks(tasks), [ + { name: 'successful_task', priority: LOW_PRIO, errored: false }, + { name: 'failed_task', priority: HIGH_PRIO, errored: true }, + { name: 'someOtherTask', priority: HIGH_PRIO, errored: false } + ]); + }); + + it('should process tasks according to priority when queue usePriority = true)', async () => { + const tasks = [ + addTaskToPrioQueue({ name: 'taskLow1' }), + addTaskToPrioQueue({ name: 'taskHigh1', priority: HIGH_PRIO }), + addTaskToPrioQueue({ name: 'taskLow2' }), + addTaskToPrioQueue({ name: 'taskHigh2', priority: HIGH_PRIO }) + ]; + await assert.becomes(processTasks(tasks), [ + { name: 'taskHigh1', priority: HIGH_PRIO, errored: false }, + { name: 'taskHigh2', priority: HIGH_PRIO, errored: false }, + { name: 'taskLow1', priority: LOW_PRIO, errored: false }, + { name: 'taskLow2', priority: LOW_PRIO, errored: false } + ]); + }); + + it('should support multiple workers queue concurrency > 1)', async () => { + const tasks = [ + addTaskToMultiQueue(testUtil.deepCopy(taskSucceeds)), + addTaskToMultiQueue(testUtil.deepCopy(taskSucceeds)), + addTaskToMultiQueue(testUtil.deepCopy(taskSucceeds)), + addTaskToMultiQueue(testUtil.deepCopy(taskSucceeds)) + ]; + const results = await processTasks(tasks, true, true); + assert.lengthOf(Array.from(new Set(results.map((t) => t.workerID))), 2, 'should utilize all workers'); + }); + + it('should not add task when exceeded maxSize', async () => { + const tq = new TaskQueue(processTask, { maxSize: 3 }); + const tasks = [ + addTaskToQueue({ name: 'task1' }, tq), + addTaskToQueue({ name: 'task2' }, tq), + addTaskToQueue({ name: 'task3' }, tq), + addTaskToQueue({ name: 'task4' }, tq), + addTaskToQueue({ name: 'task5' }, tq) + ]; + + await promiseUtil.allSettled(tasks); + assert.lengthOf(processed, 3, 'it should run 3 tasks only'); + }); + + it('should cleanup queue', async () => { + addTaskToQueue({ name: 'task1' }); + addTaskToQueue({ name: 'task2' }); + addTaskToQueue({ name: 'task3' }); + addTaskToQueue({ name: 'task4' }); + addTaskToQueue({ name: 'task5' }); + + assert.deepStrictEqual(taskQ.size(), 5); + + taskQ.clear(); + assert.deepStrictEqual(taskQ.size(), 0); + + taskQ.clear(); + assert.deepStrictEqual(taskQ.size(), 0); + + // should be run after all + const taskPromise = addTaskToQueue({ name: 'task6' }); + assert.deepStrictEqual(taskQ.size(), 1); + + const result = await taskPromise; + assert.deepStrictEqual(taskQ.size(), 0); + + assert.deepStrictEqual(result.name, 'task6'); + assert.lengthOf(processed, 1, 'should not process removed tasks'); + }); + + it('should work fine with async function', async () => { + const tq = new TaskQueue(async (task, cb, info) => { + processTask(task, cb, info); + }); + + const tasks = [ + addTaskToQueue({ name: 'task1' }, tq), + addTaskToQueue({ name: 'task2' }, tq), + addTaskToQueue({ name: 'task3' }, tq) + ]; + + await assert.becomes(processTasks(tasks), [ + { name: 'task1', priority: LOW_PRIO, errored: false }, + { name: 'task2', priority: LOW_PRIO, errored: false }, + { name: 'task3', priority: LOW_PRIO, errored: false } + ]); + }); + + it('should log error for sync task', async () => { + const tq = new TaskQueue(() => { + throw new Error('expected sync error'); + }); + tq.push('test'); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.error, + /Task.*failed with uncaught error after.*[\s\S]+expected sync error/ + ); + return true; + }, true); + }); + + it('should log error for async task', async () => { + const tq = new TaskQueue(async () => { + throw new Error('expected async error'); + }); + tq.push('test'); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.error, + /Task.*failed with uncaught error after.*[\s\S]+expected async error/ + ); + return true; + }, true); + }); + + it('should log error when callback "done" called multiple times', async () => { + const fns = [ + (task, cb) => { + cb(); + cb(); + cb(); + }, + (task, cb) => { + try { + cb(); + throw new Error('expected error'); + } finally { + cb(); + } + }, + async (task, cb) => { + cb(); + cb(); + cb(); + }, + async (task, cb) => { + try { + cb(); + throw new Error('expected error'); + } finally { + cb(); + } + }, + async (task, cb) => { + cb(); + await testUtil.sleep(100); + cb(); + }, + (task, cb) => { + cb(); + setTimeout(cb, 100); + } + ]; + + for (const fn of fns) { + coreStub.logger.removeAllMessages(); + assert.isEmpty(coreStub.logger.messages.warning); + + assert.isTrue((new TaskQueue(fn)).push('test')); + + await testUtil.waitTill(() => { + assert.includeMatch( + coreStub.logger.messages.warning, + /"done" callback was called multiple times for task/ + ); + return true; + }, true); + } + }); + + it('should stop and resume all workers', async () => { + const tq = new TaskQueue(async (task, done) => { + processed.push(task); + await testUtil.sleep(500); + done(); + }, { concurrency: 2 }); + + tq.push(1); + tq.push(2); + tq.push(3); + tq.push(4); + tq.push(5); + + await testUtil.waitTill(async () => { + assert.lengthOf(processed, 2); + assert.isFalse(tq.isIdle()); + await tq.stop(); + return true; + }, 1, true); + + assert.lengthOf(processed, 2); + assert.deepStrictEqual(tq.size(), 3); + assert.isTrue(tq.isIdle()); + assert.sameDeepMembers(processed, [1, 3]); + + processed = []; + tq.resume(); + + await testUtil.waitTill(async () => { + assert.lengthOf(processed, 3); + return true; + }, 1, true); + + await testUtil.sleep(1000); + assert.sameDeepMembers(processed, [2, 4, 5]); + assert.isTrue(tq.isIdle()); + }); + + it('should stop all workers (same tick)', async () => { + const tq = new TaskQueue(async (task, done) => { + processed.push(task); + await testUtil.sleep(1000); + done(); + }, { concurrency: 2 }); + + tq.push(1); + tq.push(2); + tq.push(3); + tq.push(4); + tq.push(5); + + await tq.stop(); + await testUtil.sleep(100); + + assert.deepStrictEqual(tq.size(), 5); + assert.isTrue(tq.isIdle()); + }); + + it('should allow to call .stop() multiple times', async () => { + const tq = new TaskQueue(async (task, done) => { + processed.push(task); + await testUtil.sleep(1000); + done(); + }, { concurrency: 2 }); + + tq.push(1); + tq.push(2); + + await Promise.all([ + tq.stop(), + tq.stop(), + tq.stop(), + tq.stop(), + testUtil.sleep(100) + ]); + + assert.isTrue(tq.isIdle()); + assert.deepStrictEqual(tq.size(), 2); + assert.deepStrictEqual(processed, []); + + tq.resume(); + assert.isTrue(tq.isIdle()); + + await testUtil.sleep(100); + await Promise.all([ + tq.stop(), + tq.stop(), + tq.stop(), + tq.stop(), + testUtil.sleep(100) + ]); + + assert.isTrue(tq.isIdle()); + assert.deepStrictEqual(tq.size(), 0); + assert.deepStrictEqual(processed, [1, 2]); + }); + }); +}); diff --git a/test/unit/utils/tracerTests.js b/test/unit/utils/tracerTests.js index 70d441bc..3713a32f 100644 --- a/test/unit/utils/tracerTests.js +++ b/test/unit/utils/tracerTests.js @@ -19,9 +19,7 @@ /* eslint-disable import/order */ const moduleCache = require('../shared/restoreCache')(); -const fs = require('fs'); const os = require('os'); -const path = require('path'); const sinon = require('sinon'); const assert = require('../shared/assert'); @@ -40,27 +38,12 @@ describe('Tracer', () => { const tracerFile = `${tracerDir}/tracerTest`; const fakeDate = new Date(); let coreStub; - let customFS; let tracerInst; - const readTraceFile = (filePath, encoding) => JSON.parse(fs.readFileSync(filePath, encoding || tracerEncoding)); - const emptyDir = (dirPath) => { - fs.readdirSync(dirPath).forEach((item) => { - item = path.join(dirPath, item); - if (fs.statSync(item).isDirectory()) { - emptyDir(item); - fs.rmdirSync(item); - } else { - fs.unlinkSync(item); - } - }); - }; - const removeDir = (dirPath) => { - if (fs.existsSync(dirPath)) { - emptyDir(dirPath); - fs.rmdirSync(dirPath); - } - }; + const readTraceFile = (filePath, encoding) => JSON.parse( + utilMisc.fs.readFileSync(filePath, encoding || tracerEncoding) + ); + const addTimestamps = (data) => data.map((item) => ({ data: item, timestamp: new Date().toISOString() })); before(() => { @@ -69,39 +52,20 @@ describe('Tracer', () => { beforeEach(() => { coreStub = stubs.coreStub({ - logger + logger, + utilMisc }); stubs.clock({ fakeTimersOpts: fakeDate }); - if (fs.existsSync(tracerDir)) { - emptyDir(tracerDir); - } - tracerInst = tracer.create(tracerFile); coreStub.logger.removeAllMessages(); - - customFS = sinon.spy({ - R_OK: 1, - access() { return Promise.reject(new Error('not exist')); }, - close() { return Promise.resolve(); }, - ftruncate() { return Promise.resolve(); }, - fstat() { - return Promise.resolve([{ - size: 2 - }]); - }, - mkdir() { return Promise.resolve(); }, - open() { return Promise.resolve([1]); }, - read() { return Promise.resolve([2, Buffer.from('[]')]); }, - write() { return Promise.resolve(); } - }); }); - afterEach(() => (tracerInst ? tracerInst.stop() : Promise.resolve()) - .then(() => sinon.restore())); - - after(() => { - removeDir(tracerDir); + afterEach(async () => { + if (tracerInst) { + await tracerInst.stop(); + } + sinon.restore(); }); describe('.create', () => { @@ -198,23 +162,6 @@ describe('Tracer', () => { }); }); - it('should allow to specify custom FS module', () => { - tracerInst = new tracer.Tracer('tracerFile', { - fs: customFS - }); - return tracerInst.write('somethings') - .then(() => tracerInst.stop()) - .then(() => { - assert.isAbove(customFS.access.callCount, 0, 'should call customFS.access'); - assert.isAbove(customFS.close.callCount, 0, 'should call customFS.close'); - assert.isAbove(customFS.fstat.callCount, 0, 'should call customFS.fstat'); - assert.isAbove(customFS.mkdir.callCount, 0, 'should call customFS.mkdir'); - assert.isAbove(customFS.open.callCount, 0, 'should call customFS.open'); - assert.isAbove(customFS.read.callCount, 0, 'should call customFS.read'); - assert.isAbove(customFS.write.callCount, 0, 'should call customFS.write'); - }); - }); - it('should not set inactivity timeout when 0 passed', () => { const fakeClock = stubs.clock(); @@ -306,7 +253,6 @@ describe('Tracer', () => { describe('.write()', () => { it('should try to create parent directory', () => { - sinon.stub(utilMisc.fs, 'mkdir').resolves(); tracerInst = tracer.create('/test/inaccessible/directory/file'); return tracerInst.write('foobar') .then(() => { @@ -321,8 +267,8 @@ describe('Tracer', () => { }); it('should not try to create parent directory if exist already (concurrent requests)', () => { - sinon.stub(utilMisc.fs, 'access').rejects(new Error('access error')); - sinon.stub(utilMisc.fs, 'mkdir').callsFake(() => { + utilMisc.fs.access.rejects(new Error('access error')); + utilMisc.fs.mkdir.callsFake(() => { const error = new Error('folder exists'); error.code = 'EEXIST'; return Promise.reject(error); @@ -340,7 +286,7 @@ describe('Tracer', () => { }); it('should not reject when unable to create parent directory', () => { - sinon.stub(utilMisc.fs, 'mkdir').rejects(new Error('mkdir error')); + utilMisc.fs.mkdir.rejects(new Error('mkdir error')); tracerInst = tracer.create('/test/inaccessible/directory/file'); return tracerInst.write('foobar') .then((err) => { @@ -445,8 +391,8 @@ describe('Tracer', () => { it('should not fail if unable to parse existing data', () => tracerInst.write('item1') .then((err) => { assert.isUndefined(err, 'should return no error'); - fs.truncateSync(tracerFile, 0); - fs.writeFileSync(tracerFile, '{test'); + utilMisc.fs.truncateSync(tracerFile, 0); + utilMisc.fs.writeFileSync(tracerFile, '{test'); return tracerInst.write('item1'); }) .then((err) => { @@ -574,7 +520,7 @@ describe('Tracer', () => { it('should create new write request when current one in progress already', () => { const dataHistory = []; - const fsWriteStub = sinon.stub(utilMisc.fs, 'write'); + const fsWriteStub = utilMisc.fs.write; let writePromise; fsWriteStub.callsFake(function () { @@ -614,8 +560,8 @@ describe('Tracer', () => { }); it('should batch multiple .write attempts into one', () => { - const readSpy = sinon.spy(fs, 'read'); - const writeSpy = sinon.spy(fs, 'write'); + const readSpy = utilMisc.fs.read; + const writeSpy = utilMisc.fs.write; const p1 = tracerInst.write('test1'); tracerInst.write('test2'); @@ -671,7 +617,7 @@ describe('Tracer', () => { describe('.stop()', () => { it('should not fail when unable to close file using descriptor', () => { - sinon.stub(utilMisc.fs, 'close').rejects(new Error('close error')); + utilMisc.fs.close.rejects(new Error('close error')); coreStub.logger.removeAllMessages(); return tracerInst.write('test') .then(() => tracerInst.stop()) @@ -880,7 +826,7 @@ describe('Tracer', () => { describe('suspend due inactivity', () => { it('should suspend tracer due inactivity and resume it on attempt to write data', () => { - const closeSpy = sinon.spy(utilMisc.fs, 'close'); + const closeSpy = utilMisc.fs.close; const fakeClock = stubs.clock(); tracerInst = tracer.create(tracerFile); assert.deepStrictEqual(tracerInst.inactivityTimeout, 900, 'should set default inactivity timeout'); diff --git a/versions.json b/versions.json index 37f8894f..db30a87a 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "versionMetaTimestamp": 1540928506, "latestVersion": { - "name": "1.34 (non-LTS)", + "name": "1.36 (non-LTS)", "url": "/products/extensions/f5-telemetry-streaming/latest/" }, "otherVersions": [