diff --git a/common/changes/@boostercloud/framework-core/sensor_health_2023-10-04-08-38.json b/common/changes/@boostercloud/framework-core/sensor_health_2023-10-04-08-38.json new file mode 100644 index 000000000..6bd393e18 --- /dev/null +++ b/common/changes/@boostercloud/framework-core/sensor_health_2023-10-04-08-38.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@boostercloud/framework-core", + "comment": "Add health sensor", + "type": "minor" + } + ], + "packageName": "@boostercloud/framework-core" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 2ec8a9d5b..774987bc0 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -8,8 +8,8 @@ importers: ../../packages/application-tester: specifiers: '@apollo/client': 3.7.13 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/jsonwebtoken': 9.0.1 '@types/node': ^18.15.3 @@ -70,10 +70,10 @@ importers: ../../packages/cli: specifiers: - '@boostercloud/application-tester': workspace:^1.20.0 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-core': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/application-tester': workspace:^2.0.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-core': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@oclif/core': ^3.9.0 '@oclif/plugin-help': ^5 @@ -181,8 +181,8 @@ importers: ../../packages/framework-common-helpers: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/chai': 4.2.18 '@types/chai-as-promised': 7.1.4 @@ -250,10 +250,10 @@ importers: ../../packages/framework-core: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 - '@boostercloud/metadata-booster': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 + '@boostercloud/metadata-booster': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/chai': 4.2.18 '@types/chai-as-promised': 7.1.4 @@ -350,19 +350,19 @@ importers: ../../packages/framework-integration-tests: specifiers: '@apollo/client': 3.7.13 - '@boostercloud/application-tester': workspace:^1.20.0 - '@boostercloud/cli': workspace:^1.20.0 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-core': workspace:^1.20.0 - '@boostercloud/framework-provider-aws': workspace:^1.20.0 - '@boostercloud/framework-provider-aws-infrastructure': workspace:^1.20.0 - '@boostercloud/framework-provider-azure': workspace:^1.20.0 - '@boostercloud/framework-provider-azure-infrastructure': workspace:^1.20.0 - '@boostercloud/framework-provider-local': workspace:^1.20.0 - '@boostercloud/framework-provider-local-infrastructure': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 - '@boostercloud/metadata-booster': workspace:^1.20.0 + '@boostercloud/application-tester': workspace:^2.0.0 + '@boostercloud/cli': workspace:^2.0.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-core': workspace:^2.0.0 + '@boostercloud/framework-provider-aws': workspace:^2.0.0 + '@boostercloud/framework-provider-aws-infrastructure': workspace:^2.0.0 + '@boostercloud/framework-provider-azure': workspace:^2.0.0 + '@boostercloud/framework-provider-azure-infrastructure': workspace:^2.0.0 + '@boostercloud/framework-provider-local': workspace:^2.0.0 + '@boostercloud/framework-provider-local-infrastructure': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 + '@boostercloud/metadata-booster': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/aws-lambda': 8.10.48 '@types/chai': 4.2.18 @@ -484,9 +484,9 @@ importers: ../../packages/framework-provider-aws: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/aws-lambda': 8.10.48 '@types/chai': 4.2.18 @@ -580,10 +580,10 @@ importers: '@aws-cdk/core': ^1.170.0 '@aws-cdk/custom-resources': ^1.170.0 '@aws-cdk/cx-api': ^1.170.0 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-provider-aws': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-provider-aws': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/archiver': 5.1.0 '@types/aws-lambda': 8.10.48 @@ -696,9 +696,9 @@ importers: '@azure/functions': ^1.2.2 '@azure/identity': ~2.1.0 '@azure/web-pubsub': ~1.1.0 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/chai': 4.2.18 '@types/chai-as-promised': 7.1.4 @@ -769,11 +769,11 @@ importers: '@azure/arm-resources': ^5.0.1 '@azure/cosmos': ^4.0.0 '@azure/identity': ~2.1.0 - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-core': workspace:^1.20.0 - '@boostercloud/framework-provider-azure': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-core': workspace:^2.0.0 + '@boostercloud/framework-provider-azure': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@cdktf/provider-azurerm': 5.0.13 '@cdktf/provider-time': 5.0.0 '@effect-ts/core': ^0.60.4 @@ -880,9 +880,9 @@ importers: ../../packages/framework-provider-local: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/chai': 4.2.18 '@types/chai-as-promised': 7.1.4 @@ -961,10 +961,10 @@ importers: ../../packages/framework-provider-local-infrastructure: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/framework-common-helpers': workspace:^1.20.0 - '@boostercloud/framework-provider-local': workspace:^1.20.0 - '@boostercloud/framework-types': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/framework-common-helpers': workspace:^2.0.0 + '@boostercloud/framework-provider-local': workspace:^2.0.0 + '@boostercloud/framework-types': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/chai': 4.2.18 '@types/chai-as-promised': 7.1.4 @@ -1044,8 +1044,8 @@ importers: ../../packages/framework-types: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 - '@boostercloud/metadata-booster': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 + '@boostercloud/metadata-booster': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@effect-ts/node': ~0.39.0 '@types/chai': 4.2.18 @@ -1111,7 +1111,7 @@ importers: ../../packages/metadata-booster: specifiers: - '@boostercloud/eslint-config': workspace:^1.20.0 + '@boostercloud/eslint-config': workspace:^2.0.0 '@effect-ts/core': ^0.60.4 '@types/node': ^18.15.3 '@typescript-eslint/eslint-plugin': ^5.0.0 @@ -1525,7 +1525,7 @@ packages: '@aws-cdk/aws-codecommit': 1.199.0_u6wgonek7tj4xuwvr3d2ei6crq '@aws-cdk/aws-codestarnotifications': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu '@aws-cdk/aws-ec2': 1.199.0_ylylsu27pdmlfxyxktlluxtkr4 - '@aws-cdk/aws-ecr': 1.199.0_5lpmbvgigeswzbugyujlevszcq + '@aws-cdk/aws-ecr': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-ecr-assets': 1.199.0_u6wgonek7tj4xuwvr3d2ei6crq '@aws-cdk/aws-events': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi '@aws-cdk/aws-iam': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu @@ -1533,7 +1533,7 @@ packages: '@aws-cdk/aws-logs': 1.199.0_tqi77pcvvujtgay5663ykqn7wy '@aws-cdk/aws-s3': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-s3-assets': 1.199.0_tqi77pcvvujtgay5663ykqn7wy - '@aws-cdk/aws-secretsmanager': 1.199.0_uxypjio4ejtfqgxognd5fibs2q + '@aws-cdk/aws-secretsmanager': 1.199.0_maqnqwhn36fygp3z4rdnivbxii '@aws-cdk/core': 1.199.0_kscyon7amn7dglog7cugnqvkwm '@aws-cdk/region-info': 1.199.0 constructs: 3.4.293 @@ -1700,7 +1700,7 @@ packages: constructs: ^3.3.69 dependencies: '@aws-cdk/assets': 1.199.0_6t5bexudk3vtq7zhe7acxljz2e - '@aws-cdk/aws-ecr': 1.199.0_5lpmbvgigeswzbugyujlevszcq + '@aws-cdk/aws-ecr': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-iam': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu '@aws-cdk/aws-s3': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/core': 1.199.0_kscyon7amn7dglog7cugnqvkwm @@ -1710,7 +1710,7 @@ packages: - '@aws-cdk/aws-events' dev: false - /@aws-cdk/aws-ecr/1.199.0_5lpmbvgigeswzbugyujlevszcq: + /@aws-cdk/aws-ecr/1.199.0_wim6pvar6pmwiq3fs3ksmix5ru: resolution: {integrity: sha512-C4VG9uRf8UD/cNitVvYaQvF9zKwgWZoNLf43RaUrMEpo4Q/KE3/KilYBG8lsza8B7f4yjYRey5iOpydKE68kYg==} engines: {node: '>= 14.15.0'} peerDependencies: @@ -1721,8 +1721,11 @@ packages: dependencies: '@aws-cdk/aws-events': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi '@aws-cdk/aws-iam': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu + '@aws-cdk/aws-kms': 1.199.0_iumdymv27iwprkm3rzoqxlpuia '@aws-cdk/core': 1.199.0_kscyon7amn7dglog7cugnqvkwm constructs: 3.4.293 + transitivePeerDependencies: + - '@aws-cdk/cx-api' dev: false /@aws-cdk/aws-ecs/1.199.0_7m5azgjywxpgdeoosvoxrcdts4: @@ -1743,7 +1746,7 @@ packages: '@aws-cdk/aws-certificatemanager': 1.199.0_aklsbzsp6i2n6pzp4sxy6hufne '@aws-cdk/aws-cloudwatch': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi '@aws-cdk/aws-ec2': 1.199.0_ylylsu27pdmlfxyxktlluxtkr4 - '@aws-cdk/aws-ecr': 1.199.0_5lpmbvgigeswzbugyujlevszcq + '@aws-cdk/aws-ecr': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-ecr-assets': 1.199.0_u6wgonek7tj4xuwvr3d2ei6crq '@aws-cdk/aws-elasticloadbalancing': 1.199.0_ehdkigggl2baqdi4l6p7upw4f4 '@aws-cdk/aws-elasticloadbalancingv2': 1.199.0_74oizvoelbxgex3gbujl2xzm54 @@ -1755,7 +1758,7 @@ packages: '@aws-cdk/aws-route53-targets': 1.199.0_6qn3q4quwxpkjgl3z2r2rmaqqi '@aws-cdk/aws-s3': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-s3-assets': 1.199.0_tqi77pcvvujtgay5663ykqn7wy - '@aws-cdk/aws-secretsmanager': 1.199.0_uxypjio4ejtfqgxognd5fibs2q + '@aws-cdk/aws-secretsmanager': 1.199.0_maqnqwhn36fygp3z4rdnivbxii '@aws-cdk/aws-servicediscovery': 1.199.0_4axszbdsnc7kzhsa3r5g56z4bu '@aws-cdk/aws-sns': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-sqs': 1.199.0_iumdymv27iwprkm3rzoqxlpuia @@ -2001,7 +2004,7 @@ packages: '@aws-cdk/aws-lambda': 1.199.0_5pbncl2no5vqinyo3n2ekkob5q '@aws-cdk/aws-s3': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-s3-notifications': 1.199.0_fufarp47blk4okwshjegj2wjg4 - '@aws-cdk/aws-secretsmanager': 1.199.0_uxypjio4ejtfqgxognd5fibs2q + '@aws-cdk/aws-secretsmanager': 1.199.0_maqnqwhn36fygp3z4rdnivbxii '@aws-cdk/aws-sns': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-sns-subscriptions': 1.199.0_x7skkdpav5hf4ncc76dwztyszi '@aws-cdk/aws-sqs': 1.199.0_iumdymv27iwprkm3rzoqxlpuia @@ -2029,7 +2032,7 @@ packages: '@aws-cdk/aws-cloudwatch': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi '@aws-cdk/aws-codeguruprofiler': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi '@aws-cdk/aws-ec2': 1.199.0_ylylsu27pdmlfxyxktlluxtkr4 - '@aws-cdk/aws-ecr': 1.199.0_5lpmbvgigeswzbugyujlevszcq + '@aws-cdk/aws-ecr': 1.199.0_wim6pvar6pmwiq3fs3ksmix5ru '@aws-cdk/aws-ecr-assets': 1.199.0_u6wgonek7tj4xuwvr3d2ei6crq '@aws-cdk/aws-efs': 1.199.0_ylylsu27pdmlfxyxktlluxtkr4 '@aws-cdk/aws-events': 1.199.0_wcptolxmxi6sy3vjqhvgbrrnvi @@ -2226,7 +2229,7 @@ packages: constructs: 3.4.293 dev: false - /@aws-cdk/aws-secretsmanager/1.199.0_uxypjio4ejtfqgxognd5fibs2q: + /@aws-cdk/aws-secretsmanager/1.199.0_maqnqwhn36fygp3z4rdnivbxii: resolution: {integrity: sha512-Dj0+q7I9xRwg1hHowrHb9rxmicDGZVmQixFaFuBdsS5zNfBNdC6WJGWhDddJDZIPclip06fXBm/by4+l4XUpNw==} engines: {node: '>= 14.15.0'} peerDependencies: @@ -2236,12 +2239,18 @@ packages: '@aws-cdk/cx-api': 1.199.0 constructs: ^3.3.69 dependencies: + '@aws-cdk/aws-ec2': 1.199.0_ylylsu27pdmlfxyxktlluxtkr4 '@aws-cdk/aws-iam': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu + '@aws-cdk/aws-kms': 1.199.0_iumdymv27iwprkm3rzoqxlpuia '@aws-cdk/aws-lambda': 1.199.0_5pbncl2no5vqinyo3n2ekkob5q '@aws-cdk/aws-sam': 1.199.0_xwfh4icwyvj4zfjhzlqde6qllu '@aws-cdk/core': 1.199.0_kscyon7amn7dglog7cugnqvkwm '@aws-cdk/cx-api': 1.199.0 constructs: 3.4.293 + transitivePeerDependencies: + - '@aws-cdk/assets' + - '@aws-cdk/aws-logs' + - '@aws-cdk/aws-s3' dev: false /@aws-cdk/aws-servicediscovery/1.199.0_4axszbdsnc7kzhsa3r5g56z4bu: @@ -3299,7 +3308,7 @@ packages: dev: true /@pkgjs/parseargs/0.11.0: - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://repo1.uhc.com:443/artifactory/api/npm/npm-virtual/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} engines: {node: '>=14'} requiresBuild: true dev: true @@ -6229,7 +6238,7 @@ packages: dev: true /fsevents/2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==, tarball: https://repo1.uhc.com:443/artifactory/api/npm/npm-virtual/fsevents/-/fsevents-2.3.2.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true diff --git a/packages/application-tester/src/application-tester.ts b/packages/application-tester/src/application-tester.ts index 145b84d1d..08b183f87 100644 --- a/packages/application-tester/src/application-tester.ts +++ b/packages/application-tester/src/application-tester.ts @@ -1,14 +1,17 @@ import { GraphQLHelper } from './graphql-helper' import { Counters, ProviderTestHelper, Queries } from './provider-test-helper' import { TokenHelper } from './token-helper' +import { HttpHelper } from './http-helper' export class ApplicationTester { readonly graphql: GraphQLHelper + readonly http: HttpHelper readonly token: TokenHelper readonly count: Counters readonly query: Queries constructor(providerTestHelper: ProviderTestHelper) { this.graphql = new GraphQLHelper(providerTestHelper) + this.http = new HttpHelper(providerTestHelper) this.token = new TokenHelper() this.count = providerTestHelper.counters this.query = providerTestHelper.queries diff --git a/packages/application-tester/src/http-helper.ts b/packages/application-tester/src/http-helper.ts new file mode 100644 index 000000000..684f2ea46 --- /dev/null +++ b/packages/application-tester/src/http-helper.ts @@ -0,0 +1,8 @@ +import { ProviderTestHelper } from './provider-test-helper' + +export class HttpHelper { + constructor(private providerTestHelper: ProviderTestHelper) {} + public getHealthUrl(): string { + return this.providerTestHelper.outputs.healthURL + } +} diff --git a/packages/application-tester/src/provider-test-helper.ts b/packages/application-tester/src/provider-test-helper.ts index 2e34a844e..109721fa8 100644 --- a/packages/application-tester/src/provider-test-helper.ts +++ b/packages/application-tester/src/provider-test-helper.ts @@ -1,6 +1,7 @@ export interface ApplicationOutputs { graphqlURL: string websocketURL: string + healthURL: string } export interface Counters { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f3fc72901..f53951d81 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -2,11 +2,12 @@ import { Flags } from '@oclif/core' import BaseCommand from '../common/base-command' import { startProvider } from '../services/provider-service' import { compileProjectAndLoadConfig } from '../services/config-service' -import { BoosterConfig } from '@boostercloud/framework-types' +import { BOOSTER_LOCAL_PORT, BoosterConfig } from '@boostercloud/framework-types' import { Script } from '../common/script' import Brand from '../common/brand' import { logger } from '../services/logger' import { currentEnvironment, initializeEnvironment } from '../services/environment' +import * as process from 'process' const runTasks = async ( port: number, @@ -41,6 +42,7 @@ export default class Start extends BaseCommand { const { flags } = await this.parse(Start) if (initializeEnvironment(logger, flags.environment)) { + process.env[BOOSTER_LOCAL_PORT] = flags.port ? flags.port.toString() : '3000' await runTasks(flags.port, compileProjectAndLoadConfig(process.cwd()), startProvider.bind(null, flags.port)) } } diff --git a/packages/cli/src/templates/project/index-ts.ts b/packages/cli/src/templates/project/index-ts.ts index cc4d8b3d6..fa4757028 100644 --- a/packages/cli/src/templates/project/index-ts.ts +++ b/packages/cli/src/templates/project/index-ts.ts @@ -4,6 +4,7 @@ export { boosterEventDispatcher, boosterServeGraphQL, boosterNotifySubscribers, + boosterHealth, boosterTriggerScheduledCommand, boosterRocketDispatcher, } from '@boostercloud/framework-core' diff --git a/packages/cli/test/fixtures/mock_project/src/index.ts b/packages/cli/test/fixtures/mock_project/src/index.ts index a5f8cbf98..ef6a7ba31 100644 --- a/packages/cli/test/fixtures/mock_project/src/index.ts +++ b/packages/cli/test/fixtures/mock_project/src/index.ts @@ -3,6 +3,7 @@ export { Booster, boosterEventDispatcher, boosterPreSignUpChecker, + boosterHealth, boosterServeGraphQL, boosterNotifySubscribers, boosterTriggerScheduledCommand, diff --git a/packages/cli/test/fixtures/mock_project_bad_index/src/index.ts b/packages/cli/test/fixtures/mock_project_bad_index/src/index.ts index b0cd1cfcb..71abcbb69 100644 --- a/packages/cli/test/fixtures/mock_project_bad_index/src/index.ts +++ b/packages/cli/test/fixtures/mock_project_bad_index/src/index.ts @@ -3,9 +3,8 @@ export { Booster, boosterEventDispatcher, boosterPreSignUpChecker, + boosterHealth, boosterServeGraphQL, boosterRequestAuthorizer, boosterNotifySubscribers, } from '@boostercloud/framework-core' - - diff --git a/packages/framework-common-helpers/src/http-service.ts b/packages/framework-common-helpers/src/http-service.ts new file mode 100644 index 000000000..3f5026886 --- /dev/null +++ b/packages/framework-common-helpers/src/http-service.ts @@ -0,0 +1,67 @@ +import * as https from 'https' +import * as http from 'http' +import { RequestOptions } from 'https' +import { IncomingMessage } from 'node:http' + +export interface PostConfiguration { + contentType?: string + timeout?: number +} + +export interface PostResult { + status: number + body: unknown +} + +export async function request( + url: string, + method: 'GET' | 'POST' = 'GET', + data = '', + config: PostConfiguration = {} +): Promise { + const { contentType = 'application/json', timeout = 10000 } = config + const options: RequestOptions = { + method: method, + headers: { + 'Content-Type': contentType, + }, + timeout: timeout, + } + if (data) { + options.headers!['Content-Length'] = data.length + } + + return new Promise((resolve, reject) => { + const method = url.startsWith('https') ? https.request : http.request + const request = method(url, options, (res: IncomingMessage) => { + const body: Array = [] + res.on('data', (chunk) => body.push(chunk)) + res.on('end', () => { + if (!res?.statusCode) { + return reject(new Error('Unknown HTTP status code')) + } + if (res.statusCode < 200 || res.statusCode > 299) { + return reject(new Error(`HTTP status code ${res.statusCode}`)) + } + + const buffer = Buffer.concat(body).toString() + resolve({ + status: res?.statusCode, + body: buffer, + }) + }) + }) + + request.on('error', (err) => { + reject(err) + }) + + request.on('timeout', () => { + request.destroy() + reject(new Error('Request time out')) + }) + + request.write(data) + request.end() + }) +} diff --git a/packages/framework-common-helpers/src/index.ts b/packages/framework-common-helpers/src/index.ts index 88548ca69..ad83b44b8 100644 --- a/packages/framework-common-helpers/src/index.ts +++ b/packages/framework-common-helpers/src/index.ts @@ -6,3 +6,4 @@ export * from './instances' export * from './run-command' export * from './logger' export * from './rocket-loader' +export * from './http-service' diff --git a/packages/framework-core/src/booster-authorizer.ts b/packages/framework-core/src/booster-authorizer.ts index d04bd8dab..0e30206b3 100644 --- a/packages/framework-core/src/booster-authorizer.ts +++ b/packages/framework-core/src/booster-authorizer.ts @@ -9,6 +9,8 @@ import { ReadModelRoleAccess, CommandAuthorizer, ReadModelAuthorizer, + HealthRoleAccess, + HealthAuthorizer, } from '@boostercloud/framework-types' export class BoosterAuthorizer { @@ -28,9 +30,10 @@ export class BoosterAuthorizer { } public static build( - attributes: CommandRoleAccess | QueryRoleAccess | ReadModelRoleAccess - ): CommandAuthorizer | QueryAuthorizer | ReadModelAuthorizer { - let authorizer: CommandAuthorizer | QueryAuthorizer | ReadModelAuthorizer = BoosterAuthorizer.denyAccess + attributes: CommandRoleAccess | QueryRoleAccess | ReadModelRoleAccess | HealthRoleAccess + ): CommandAuthorizer | QueryAuthorizer | ReadModelAuthorizer | HealthAuthorizer { + let authorizer: CommandAuthorizer | QueryAuthorizer | ReadModelAuthorizer | HealthAuthorizer = + BoosterAuthorizer.denyAccess if (attributes.authorize === 'all') { authorizer = BoosterAuthorizer.allowAccess } else if (Array.isArray(attributes.authorize)) { diff --git a/packages/framework-core/src/booster.ts b/packages/framework-core/src/booster.ts index b4026562f..c8e4c6e26 100644 --- a/packages/framework-core/src/booster.ts +++ b/packages/framework-core/src/booster.ts @@ -31,6 +31,7 @@ import { BoosterAuthorizer } from './booster-authorizer' import { BoosterReadModelsReader } from './booster-read-models-reader' import { BoosterEntityTouched } from './core-concepts/touch-entity/events/booster-entity-touched' import { eventSearch } from './booster-event-search' +import { BoosterHealthService } from './sensor' /** * Main class to interact with Booster and configure it. @@ -152,6 +153,10 @@ export class Booster { return new BoosterRocketDispatcher(this.config).dispatch(request) } + public static dispatchBoosterHealth(request: unknown): Promise { + return new BoosterHealthService(this.config).boosterHealth(request) + } + private static configureBoosterConcepts(): void { this.configureDataMigrations() this.configureTouchEntities() @@ -245,3 +250,7 @@ export async function boosterNotifySubscribers(rawRequest: unknown): Promise { return Booster.dispatchRocket(rawRequest) } + +export async function boosterHealth(rawRequest: unknown): Promise { + return Booster.dispatchBoosterHealth(rawRequest) +} diff --git a/packages/framework-core/src/decorators/health-sensor.ts b/packages/framework-core/src/decorators/health-sensor.ts new file mode 100644 index 000000000..822685978 --- /dev/null +++ b/packages/framework-core/src/decorators/health-sensor.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { + Class, + HealthIndicatorConfiguration, + HealthIndicatorInterface, + HealthIndicatorMetadata, +} from '@boostercloud/framework-types' +import { Booster } from '../booster' +import { defaultBoosterHealthIndicators } from '../sensor/health/health-indicators' + +/** + * + * @param {Object} attributes + * @param {string} attributes.id - Unique indicator identifier + * @param {string} attributes.name - Indicator description + * @param {boolean} attributes.enabled - If false, this indicator and the components of this indicator will be skipped + * @param {boolean} attributes.details - If false, the indicator will not include the details + * @param {boolean} [attributes.showChildren] - If false, this indicator will not include children components in the tree. + * Children components will be shown through children urls + * @constructor + */ +export function HealthSensor( + attributes: HealthIndicatorConfiguration +): (healthIndicator: Class) => void { + return (healthIndicator) => { + Booster.configureCurrentEnv((config): void => { + if (Object.keys(config.userHealthIndicators).length === 0) { + config.userHealthIndicators = defaultBoosterHealthIndicators(config) + } + const path = attributes.id + config.userHealthIndicators[path] = { + class: healthIndicator, + healthIndicatorConfiguration: { + id: attributes.id, + name: attributes.name, + enabled: attributes.enabled, + details: attributes.details, + showChildren: attributes.showChildren ?? true, + }, + } as HealthIndicatorMetadata + }) + } +} diff --git a/packages/framework-core/src/index.ts b/packages/framework-core/src/index.ts index b4962ebc1..bd43437f2 100644 --- a/packages/framework-core/src/index.ts +++ b/packages/framework-core/src/index.ts @@ -12,8 +12,10 @@ export { boosterNotifySubscribers, boosterTriggerScheduledCommand, boosterRocketDispatcher, + boosterHealth, } from './booster' export * from './services/token-verifiers' export * from './instrumentation/index' +export * from './decorators/health-sensor' export const Booster: BoosterApp = boosterModule.Booster diff --git a/packages/framework-core/src/sensor/health/booster-health-service.ts b/packages/framework-core/src/sensor/health/booster-health-service.ts new file mode 100644 index 000000000..74dc231c4 --- /dev/null +++ b/packages/framework-core/src/sensor/health/booster-health-service.ts @@ -0,0 +1,112 @@ +import { + BoosterConfig, + HealthAuthorizer, + HealthEnvelope, + HealthIndicatorMetadata, + HealthIndicatorResult, + HealthIndicatorsResult, + UserEnvelope, +} from '@boostercloud/framework-types' +import { childrenHealthProviders, isEnabled, metadataFromId, rootHealthProviders } from './health-utils' +import { createInstance } from '@boostercloud/framework-common-helpers' +import { defaultBoosterHealthIndicators } from './health-indicators' +import { BoosterTokenVerifier } from '../../booster-token-verifier' +import { BoosterAuthorizer } from '../../booster-authorizer' + +export class BoosterHealthService { + constructor(readonly config: BoosterConfig) {} + + public async boosterHealth(request: any): Promise { + try { + const healthEnvelope: HealthEnvelope = this.config.provider.sensor.rawRequestToHealthEnvelope(request) + await this.validate(healthEnvelope) + const healthProviders = this.getHealthProviders() + const parents = this.parentsHealthProviders(healthEnvelope, healthProviders) + const healthIndicatorResults = await this.boosterHealthProviderResolver(parents, healthProviders) + return await this.config.provider.api.requestSucceeded(healthIndicatorResults) + } catch (e) { + return await this.config.provider.api.requestFailed(e) + } + } + + private async validate(healthEnvelope: HealthEnvelope): Promise { + const userEnvelope = await this.verify(healthEnvelope) + const authorizer = BoosterAuthorizer.build( + this.config.sensorConfiguration.health.globalAuthorizer + ) as HealthAuthorizer + await authorizer(userEnvelope, healthEnvelope) + } + + private async boosterHealthProviderResolver( + healthIndicatorsMetadata: Array, + healthProviders: Record + ): Promise> { + const result: Array = [] + for (const current of healthIndicatorsMetadata) { + const indicatorResult = await this.enabledIndicatorHealth(current, healthProviders) + if (!indicatorResult) { + continue + } + const childrens = childrenHealthProviders(current, healthProviders) + const newResult: HealthIndicatorsResult = { + ...indicatorResult, + name: current.healthIndicatorConfiguration.name, + id: current.healthIndicatorConfiguration.id, + } + if (childrens && childrens?.length > 0) { + newResult.components = await this.boosterHealthProviderResolver(childrens, healthProviders) + } + result.push(newResult) + } + return result + } + + private async enabledIndicatorHealth( + current: HealthIndicatorMetadata, + healthProviders: Record + ): Promise { + if (isEnabled(current, healthProviders)) { + return await this.indicatorHealth(current) + } + return + } + + private async indicatorHealth(metadata: HealthIndicatorMetadata): Promise { + const rootClass = metadata.class + const instance = createInstance(rootClass, {}) + const healthIndicatorResult = await instance.health(this.config, metadata) + if (!metadata.healthIndicatorConfiguration.details) { + healthIndicatorResult.details = undefined + } + return healthIndicatorResult + } + + /** + * If there is not any indicator configured, then we will use only the Booster indicators. + * @private + */ + private getHealthProviders(): Record { + return Object.keys(this.config.userHealthIndicators).length !== 0 + ? this.config.userHealthIndicators + : defaultBoosterHealthIndicators(this.config) + } + + private parentsHealthProviders( + envelope: HealthEnvelope, + healthProviders: Record + ): Array { + const componentPath = envelope.componentPath + return componentPath && componentPath.length > 0 + ? [metadataFromId(healthProviders, componentPath)] + : rootHealthProviders(healthProviders) + } + + private async verify(envelope: HealthEnvelope): Promise { + const boosterTokenVerifier = new BoosterTokenVerifier(this.config) + const token = envelope.token + if (!token) { + return + } + return await boosterTokenVerifier.verify(token) + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-database-events-health-indicator.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-database-events-health-indicator.ts new file mode 100644 index 000000000..c937fded6 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-database-events-health-indicator.ts @@ -0,0 +1,31 @@ +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' + +export class BoosterDatabaseEventsHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + try { + const result: HealthIndicatorResult = { + status: await this.isUp(config), + } + if (healthIndicatorMetadata.healthIndicatorConfiguration.details) { + const details = await config.provider.sensor.databaseEventsHealthDetails(config) + result.details = details as any + } + return result + } catch (e) { + return { status: HealthStatus.DOWN, details: e } + } + } + + private async isUp(config: BoosterConfig): Promise { + const databaseEvents = await config.provider.sensor.isDatabaseEventUp(config) + return databaseEvents ? HealthStatus.UP : HealthStatus.DOWN + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-database-health-indicator.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-database-health-indicator.ts new file mode 100644 index 000000000..5a4564043 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-database-health-indicator.ts @@ -0,0 +1,34 @@ +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' + +export class BoosterDatabaseHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + try { + const result: HealthIndicatorResult = { + status: await this.isUp(config), + } + if (healthIndicatorMetadata.healthIndicatorConfiguration.details) { + const details = { + urls: await config.provider.sensor.databaseUrls(config), + } + result.details = details as any + } + return result + } catch (e) { + return { status: HealthStatus.DOWN, details: e } + } + } + + private async isUp(config: BoosterConfig): Promise { + const databaseEvents = await config.provider.sensor.isDatabaseEventUp(config) + const databaseReadModels = await config.provider.sensor.areDatabaseReadModelsUp(config) + return databaseEvents && databaseReadModels ? HealthStatus.UP : HealthStatus.DOWN + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-database-read-models-health-indicator.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-database-read-models-health-indicator.ts new file mode 100644 index 000000000..37c09b795 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-database-read-models-health-indicator.ts @@ -0,0 +1,31 @@ +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' + +export class BoosterDatabaseReadModelsHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + try { + const result: HealthIndicatorResult = { + status: await this.isUp(config), + } + if (healthIndicatorMetadata.healthIndicatorConfiguration.details) { + const details = await config.provider.sensor.databaseReadModelsHealthDetails(config) + result.details = details as any + } + return result + } catch (e) { + return { status: HealthStatus.DOWN, details: e } + } + } + + private async isUp(config: BoosterConfig): Promise { + const databaseReadModels = await config.provider.sensor.areDatabaseReadModelsUp(config) + return databaseReadModels ? HealthStatus.UP : HealthStatus.DOWN + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-function-health-indicator.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-function-health-indicator.ts new file mode 100644 index 000000000..1dfd8ef5b --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-function-health-indicator.ts @@ -0,0 +1,35 @@ +import { + BoosterConfig, + HealthIndicatorMetadata, + HealthIndicatorResult, + HealthStatus, +} from '@boostercloud/framework-types' +import { osInfo } from './os-info' + +export class BoosterFunctionHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + try { + const result: HealthIndicatorResult = { + status: await this.isUp(config), + } + if (healthIndicatorMetadata.healthIndicatorConfiguration.details) { + const graphQLUrl = await config.provider.sensor.graphQLFunctionUrl(config) + const osInfoResult = await osInfo() + result.details = { + ...osInfoResult, + graphQL_url: graphQLUrl, + } + } + return result + } catch (e) { + return { status: HealthStatus.DOWN, details: e } + } + } + + private async isUp(config: BoosterConfig): Promise { + return (await config.provider.sensor.isGraphQLFunctionUp(config)) ? HealthStatus.UP : HealthStatus.DOWN + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-health-indicator.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-health-indicator.ts new file mode 100644 index 000000000..7cf9191a9 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-health-indicator.ts @@ -0,0 +1,35 @@ +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' +import { boosterVersion } from './booster-version' + +export class BoosterHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + try { + const result: HealthIndicatorResult = { + status: await this.isUp(config), + } + if (healthIndicatorMetadata.healthIndicatorConfiguration.details) { + result.details = { + boosterVersion: boosterVersion(config), + } + } + return result + } catch (e) { + return { status: HealthStatus.DOWN, details: e } + } + } + + private async isUp(config: BoosterConfig): Promise { + const graphqlUp = await config.provider.sensor.isGraphQLFunctionUp(config) + const databaseEvents = await config.provider.sensor.isDatabaseEventUp(config) + const databaseReadModels = await config.provider.sensor.areDatabaseReadModelsUp(config) + return graphqlUp && databaseEvents && databaseReadModels ? HealthStatus.UP : HealthStatus.DOWN + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/booster-version.ts b/packages/framework-core/src/sensor/health/health-indicators/booster-version.ts new file mode 100644 index 000000000..48085dc7f --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/booster-version.ts @@ -0,0 +1,22 @@ +import * as path from 'path' +import * as process from 'process' +import { getLogger } from '@boostercloud/framework-common-helpers' +import { BoosterConfig } from '@boostercloud/framework-types' + +export function boosterVersion(config: BoosterConfig) { + const projectAbsolutePath = path.resolve(process.cwd()) + const logger = getLogger(config, 'boosterVersion') + try { + const packageJsonContents = require(path.join(projectAbsolutePath, 'package.json')) + const version = packageJsonContents.dependencies['@boostercloud/framework-core'] + if (!version) { + logger.warn('Could not get Booster Version') + return '' + } + const versionParts = version.replace('workspace:', '').replace('^', '').replace('.tgz', '').split('-') + return versionParts[versionParts.length - 1] + } catch (e) { + logger.warn('There was an error when calculating the booster version the application', e) + return '' + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/default-booster-health-indicators.ts b/packages/framework-core/src/sensor/health/health-indicators/default-booster-health-indicators.ts new file mode 100644 index 000000000..05eca5611 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/default-booster-health-indicators.ts @@ -0,0 +1,69 @@ +import { + BOOSTER_HEALTH_INDICATORS_IDS, + BoosterConfig, + Class, + HealthIndicatorInterface, + HealthIndicatorMetadata, +} from '@boostercloud/framework-types' +import { BoosterHealthIndicator } from './booster-health-indicator' +import { BoosterDatabaseHealthIndicator } from './booster-database-health-indicator' +import { BoosterDatabaseEventsHealthIndicator } from './booster-database-events-health-indicator' +import { BoosterFunctionHealthIndicator } from './booster-function-health-indicator' +import { BoosterDatabaseReadModelsHealthIndicator } from './booster-database-read-models-health-indicator' + +function buildMetadata( + config: BoosterConfig, + id: BOOSTER_HEALTH_INDICATORS_IDS, + name: string, + boosterHealthIndicator: Class +): HealthIndicatorMetadata { + const health = config.sensorConfiguration.health + return { + class: boosterHealthIndicator, + healthIndicatorConfiguration: { + id: id, + name: name, + enabled: health.booster[id].enabled, + details: health.booster[id].details, + showChildren: health.booster[id].showChildren, + }, + } +} + +/** + * Booster configured HealthIndicators + */ +export function defaultBoosterHealthIndicators(config: BoosterConfig): Record { + const root = buildMetadata(config, BOOSTER_HEALTH_INDICATORS_IDS.ROOT, 'Booster', BoosterHealthIndicator) + const boosterFunction = buildMetadata( + config, + BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION, + 'Booster Function', + BoosterFunctionHealthIndicator + ) + const boosterDatabase = buildMetadata( + config, + BOOSTER_HEALTH_INDICATORS_IDS.DATABASE, + 'Booster Database', + BoosterDatabaseHealthIndicator + ) + const databaseEvents = buildMetadata( + config, + BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS, + 'Booster Database Events', + BoosterDatabaseEventsHealthIndicator + ) + const databaseReadModels = buildMetadata( + config, + BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS, + 'Booster Database ReadModels', + BoosterDatabaseReadModelsHealthIndicator + ) + return { + [root.healthIndicatorConfiguration.id]: root, + [boosterFunction.healthIndicatorConfiguration.id]: boosterFunction, + [boosterDatabase.healthIndicatorConfiguration.id]: boosterDatabase, + [databaseEvents.healthIndicatorConfiguration.id]: databaseEvents, + [databaseReadModels.healthIndicatorConfiguration.id]: databaseReadModels, + } +} diff --git a/packages/framework-core/src/sensor/health/health-indicators/index.ts b/packages/framework-core/src/sensor/health/health-indicators/index.ts new file mode 100644 index 000000000..cb3b0a666 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/index.ts @@ -0,0 +1,3 @@ +export * from './booster-health-indicator' +export * from './default-booster-health-indicators' +export * from './booster-function-health-indicator' diff --git a/packages/framework-core/src/sensor/health/health-indicators/os-info.ts b/packages/framework-core/src/sensor/health/health-indicators/os-info.ts new file mode 100644 index 000000000..bf81d0910 --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-indicators/os-info.ts @@ -0,0 +1,41 @@ +import * as os from 'os' + +export interface OsInfoCpuResult { + cpu: os.CpuInfo + timesPercentages: Array +} + +export interface OsInfoMemoryResult { + totalBytes: number + freeBytes: number +} + +export interface OsInfoResult { + cpus: Array + memory: OsInfoMemoryResult +} + +export async function osInfo(): Promise { + const cpus = os.cpus() + const cpuResult = cpus.map((cpu: os.CpuInfo) => { + // times is an object containing the number of CPU ticks spent in: user, nice, sys, idle, and irq + const totalTimes = Object.values(cpu.times).reduce((accumulator, value) => { + return accumulator + value + }, 0) + const timesPercentages = Object.values(cpu.times).map((time) => { + return Math.round((100 * time) / totalTimes) + }) + return { + cpu, + timesPercentages, + } + }) + + return { + cpus: cpuResult, + memory: { + totalBytes: os.totalmem(), + freeBytes: os.freemem(), + }, + } +} diff --git a/packages/framework-core/src/sensor/health/health-utils.ts b/packages/framework-core/src/sensor/health/health-utils.ts new file mode 100644 index 000000000..40d74542e --- /dev/null +++ b/packages/framework-core/src/sensor/health/health-utils.ts @@ -0,0 +1,69 @@ +import { HealthIndicatorMetadata } from '@boostercloud/framework-types' + +const PATH_SEPARATOR = '/' + +export function metadataFromId( + healthProviders: Record, + id: string +): HealthIndicatorMetadata { + const healthProvider = healthProviders[id] + if (!healthProvider) { + throw new Error(`Unexpected HealthProvider id ${id}`) + } + return healthProvider +} + +export function parentId(healthProvider: HealthIndicatorMetadata): string { + const childId = healthProvider.healthIndicatorConfiguration.id + const childPath = childId.split(PATH_SEPARATOR) + return childPath.slice(0, -1).join(PATH_SEPARATOR) +} + +export function rootHealthProviders( + healthProviders: Record +): Array { + return Object.values(healthProviders).filter( + (healthProvider) => healthProvider.healthIndicatorConfiguration.id.split(PATH_SEPARATOR).length === 1 + ) +} + +export function childrenHealthProviders( + healthIndicatorMetadata: HealthIndicatorMetadata, + healthProviders: Record +): Array { + if (!showChildren(healthIndicatorMetadata, healthProviders)) { + return [] + } + const currentParentId = healthIndicatorMetadata.healthIndicatorConfiguration.id + return Object.values(healthProviders).filter((healthProvider) => { + return parentId(healthProvider) === currentParentId + }) +} + +export function isEnabled( + mainIndicatorMetadata: HealthIndicatorMetadata, + healthProviders: Record +): boolean { + if (!mainIndicatorMetadata.healthIndicatorConfiguration.enabled) { + return false + } + const parent = healthProviders[parentId(mainIndicatorMetadata)] + if (!parent) { + return true + } + return isEnabled(parent, healthProviders) +} + +export function showChildren( + mainIndicatorMetadata: HealthIndicatorMetadata, + healthProviders: Record +): boolean { + if (!mainIndicatorMetadata.healthIndicatorConfiguration.showChildren) { + return false + } + const parent = healthProviders[parentId(mainIndicatorMetadata)] + if (!parent) { + return true + } + return showChildren(parent, healthProviders) +} diff --git a/packages/framework-core/src/sensor/health/index.ts b/packages/framework-core/src/sensor/health/index.ts new file mode 100644 index 000000000..540811525 --- /dev/null +++ b/packages/framework-core/src/sensor/health/index.ts @@ -0,0 +1,3 @@ +export * from '../../decorators/health-sensor' +export * from './health-indicators/index' +export * from './booster-health-service' diff --git a/packages/framework-core/src/sensor/index.ts b/packages/framework-core/src/sensor/index.ts new file mode 100644 index 000000000..39b4120dd --- /dev/null +++ b/packages/framework-core/src/sensor/index.ts @@ -0,0 +1 @@ +export * from './health/index' diff --git a/packages/framework-core/test/index.test.ts b/packages/framework-core/test/index.test.ts index 7e8cb5fd4..813874676 100644 --- a/packages/framework-core/test/index.test.ts +++ b/packages/framework-core/test/index.test.ts @@ -27,4 +27,9 @@ describe('framework-core package', () => { expect(BoosterCore.boosterRocketDispatcher).not.to.be.null expect(BoosterCore.boosterRocketDispatcher).to.equal(Booster.boosterRocketDispatcher) }) + + it('exports the `boosterHealth` function', () => { + expect(BoosterCore.boosterHealth).not.to.be.null + expect(BoosterCore.boosterHealth).to.equal(Booster.boosterHealth) + }) }) diff --git a/packages/framework-core/test/sensor/health/booster-health-service.test.ts b/packages/framework-core/test/sensor/health/booster-health-service.test.ts new file mode 100644 index 000000000..2183b8eff --- /dev/null +++ b/packages/framework-core/test/sensor/health/booster-health-service.test.ts @@ -0,0 +1,313 @@ +import { expect } from '../../expect' +import { BoosterHealthService } from '../../../src/sensor' +import { BOOSTER_HEALTH_INDICATORS_IDS, BoosterConfig, ProviderLibrary } from '@boostercloud/framework-types' +import { fake } from 'sinon' +import createJWKSMock from 'mock-jwks' +import { internet, phone, random } from 'faker' +import { JwksUriTokenVerifier } from '../../../src' + +const jwksUri = 'https://myauth0app.auth0.com/' + '.well-known/jwks.json' +const issuer = 'auth0' + +describe('BoosterHealthService', () => { + const config = new BoosterConfig('test') + before(() => { + config.provider = { + api: { + requestSucceeded: fake((request: any) => request), + requestFailed: fake((error: any) => error), + }, + } as unknown as ProviderLibrary + }) + + beforeEach(() => { + Object.values(config.sensorConfiguration.health.booster).forEach((indicator) => { + indicator.enabled = true + }) + config.sensorConfiguration.health.globalAuthorizer = { + authorize: 'all', + } + }) + + it('All indicators are UP', async () => { + config.provider.sensor = defaultSensor() + const boosterResult = await boosterHealth(config) + const boosterFunction = getBoosterFunction(boosterResult) + const boosterDatabase = getBoosterDatabase(boosterResult) + const databaseEvents = getEventDatabase(boosterDatabase) + const databaseReadModels = getReadModelsDatabase(boosterDatabase) + const expectedStatus = 'UP' + expectBooster(boosterResult, '', expectedStatus) + expectBoosterFunction(boosterFunction, '', expectedStatus) + expectBoosterDatabase(boosterDatabase, expectedStatus) + expectDatabaseEvents(databaseEvents, expectedStatus) + expectDatabaseReadModels(databaseReadModels, expectedStatus) + }) + + it('All indicators are DOWN', async () => { + config.provider.sensor = defaultSensor() + config.provider.sensor.isGraphQLFunctionUp = fake(() => false) + config.provider.sensor.isDatabaseEventUp = fake(() => false) + config.provider.sensor.areDatabaseReadModelsUp = fake(() => false) + const expectedStatus = 'DOWN' + const boosterResult = await boosterHealth(config) + const boosterFunction = getBoosterFunction(boosterResult) + const boosterDatabase = getBoosterDatabase(boosterResult) + const databaseEvents = getEventDatabase(boosterDatabase) + const databaseReadModels = getReadModelsDatabase(boosterDatabase) + expectBooster(boosterResult, '', expectedStatus) + expectBoosterFunction(boosterFunction, '', expectedStatus) + expectBoosterDatabase(boosterDatabase, expectedStatus) + expectDatabaseEvents(databaseEvents, expectedStatus) + expectDatabaseReadModels(databaseReadModels, expectedStatus) + }) + + it('Details are processed', async () => { + config.provider.sensor = defaultSensor() + config.provider.sensor.databaseEventsHealthDetails = fake(() => ({ + test: true, + })) + config.provider.sensor.databaseReadModelsHealthDetails = fake(() => ({ + test: true, + })) + const boosterResult = await boosterHealth(config) + const boosterFunction = getBoosterFunction(boosterResult) + const boosterDatabase = getBoosterDatabase(boosterResult) + const databaseEvents = getEventDatabase(boosterDatabase) + const databaseReadModels = getReadModelsDatabase(boosterDatabase) + const expectedStatus = 'UP' + expectBooster(boosterResult, '', expectedStatus) + expectBoosterFunction(boosterFunction, '', expectedStatus) + expectBoosterDatabase(boosterDatabase, expectedStatus) + expectDatabaseEventsWithDetails(databaseEvents, expectedStatus, { + test: true, + }) + expectDatabaseReadModelsWithDetails(databaseReadModels, expectedStatus, { + test: true, + }) + }) + + it('Validates with the expected Role', async () => { + const jwks = createJWKSMock('https://myauth0app.auth0.com/') + jwks.start() + const token = jwks.token({ + sub: random.uuid(), + iss: issuer, + 'custom:role': 'UserRole', + extraParam: 'claims', + anotherParam: 111, + email: internet.email(), + phoneNumber: phone.phoneNumber(), + }) + config.provider.sensor = defaultSensor(token) + config.sensorConfiguration.health.globalAuthorizer = { + authorize: [UserRole], + } + config.tokenVerifiers = [ + new JwksUriTokenVerifier(issuer, 'https://myauth0app.auth0.com/' + '.well-known/jwks.json'), + ] + const boosterResult = await boosterHealth(config) + expectBooster(boosterResult, '', 'UP') + }) + + it('Validates fails with wrong role', async () => { + const jwks = createJWKSMock('https://myauth0app.auth0.com/') + jwks.start() + const token = jwks.token({ + sub: random.uuid(), + iss: issuer, + 'custom:role': 'UserRole1', + extraParam: 'claims', + anotherParam: 111, + email: internet.email(), + phoneNumber: phone.phoneNumber(), + }) + + config.provider.sensor = defaultSensor(token) + config.sensorConfiguration.health.globalAuthorizer = { + authorize: [UserRole], + } + config.tokenVerifiers = [new JwksUriTokenVerifier(issuer, jwksUri)] + const boosterHealthService = new BoosterHealthService(config) + const boosterResult = (await boosterHealthService.boosterHealth(undefined)) as any + await jwks.stop() + expect(boosterResult.code).to.be.eq('NotAuthorizedError') + }) + + it('Only root enabled and without children and details', async () => { + config.provider.sensor = defaultSensor() + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].enabled = true + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].showChildren = false + + // get root + const boosterResult = await boosterHealth(config) + + // root without children and details + expectDefaultResult(boosterResult, 'UP', 'booster', 'Booster', 0) + expect(boosterResult.details).to.be.undefined + + // other indicators are undefined + expect(getBoosterDatabase(boosterResult)).to.be.undefined + expect(getEventDatabase(boosterResult)).to.be.undefined + expect(getBoosterFunction(boosterResult)).to.be.undefined + expect(getReadModelsDatabase(boosterResult)).to.be.undefined + }) + + it('if parent disabled then children are disabled', async () => { + config.provider.sensor = defaultSensor('', 'booster/database/readmodels') + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].enabled = true + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].showChildren = false + + const readModelsResult = await boosterHealth(config) + expect(readModelsResult).to.be.undefined + }) + + it('Only ReadModels enabled and without children and details', async () => { + config.provider.sensor = defaultSensor('', 'booster/database/readmodels') + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].enabled = true + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].enabled = true + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].enabled = true + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].enabled = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].details = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.ROOT].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS].showChildren = false + config.sensorConfiguration.health.booster[BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION].showChildren = false + + const readModelsResult = await boosterHealth(config) + expectDatabaseReadModels(readModelsResult, 'UP') + }) +}) + +function defaultSensor(token?: string, url?: string) { + return { + databaseEventsHealthDetails: fake(() => {}), + databaseReadModelsHealthDetails: fake(() => {}), + isGraphQLFunctionUp: fake(() => true), + isDatabaseEventUp: fake(() => true), + areDatabaseReadModelsUp: fake(() => true), + databaseUrls: fake(() => []), + graphQLFunctionUrl: fake(() => ''), + rawRequestToHealthEnvelope: fake(() => { + return { token: token, componentPath: url } + }), + } +} + +async function boosterHealth(config: BoosterConfig): Promise { + const boosterHealthService = new BoosterHealthService(config) + const result = (await boosterHealthService.boosterHealth(undefined)) as any + return result[0] +} + +function getBoosterFunction(boosterResult: any) { + return boosterResult.components?.find((element: any) => element.id === 'booster/function') +} + +function getBoosterDatabase(boosterResult: any) { + return boosterResult.components?.find((element: any) => element.id === 'booster/database') +} + +function getEventDatabase(boosterDatabase: any) { + return boosterDatabase.components?.find((element: any) => element.id === 'booster/database/events') +} + +function getReadModelsDatabase(boosterDatabase: any) { + return boosterDatabase.components?.find((element: any) => element.id === 'booster/database/readmodels') +} + +function expectDefaultResult(result: any, status: string, id: string, name: string, componentsLength: number) { + expect(result.id).to.be.eq(id) + expect(result.status).to.be.eq(status) + expect(result.name).to.be.eq(name) + if (componentsLength === 0) { + expect(result.components).to.be.undefined + } else { + expect(result.components.length).to.be.eq(componentsLength) + } +} + +function expectBooster(boosterResult: any, version: string, status: string): void { + expectDefaultResult(boosterResult, status, 'booster', 'Booster', 2) + expect(boosterResult.details.boosterVersion).to.be.eq(version) +} + +function expectBoosterFunction(boosterFunction: any, url: string, status: string) { + expectDefaultResult(boosterFunction, status, 'booster/function', 'Booster Function', 0) + expect(boosterFunction.details.cpus.length).to.be.gt(0) + expect(boosterFunction.details.cpus[0].timesPercentages.length).to.be.gt(0) + expect(boosterFunction.details.memory.totalBytes).to.be.gt(0) + expect(boosterFunction.details.memory.freeBytes).to.be.gt(0) + expect(boosterFunction.details.graphQL_url as string).to.be.eq(url) +} + +function expectBoosterDatabase(boosterDatabase: any, status: string): void { + expectDefaultResult(boosterDatabase, status, 'booster/database', 'Booster Database', 2) + expect(boosterDatabase.details).to.not.be.undefined +} + +function expectDatabaseEvents(databaseEvents: any, status: string): void { + expectDefaultResult(databaseEvents, status, 'booster/database/events', 'Booster Database Events', 0) + expect(databaseEvents.details).to.be.undefined +} + +function expectDatabaseEventsWithDetails(databaseEvents: any, status: string, details: any): void { + expectDefaultResult(databaseEvents, status, 'booster/database/events', 'Booster Database Events', 0) + expect(databaseEvents.details).to.be.deep.eq(details) +} + +function expectDatabaseReadModels(databaseReadModels: any, status: string): void { + expectDefaultResult( + databaseReadModels, + status, + 'booster/database/readmodels', + 'Booster Database ReadModels', + 0 + ) + expect(databaseReadModels.details).to.be.undefined +} + +function expectDatabaseReadModelsWithDetails(databaseReadModels: any, status: string, details: any): void { + expectDefaultResult( + databaseReadModels, + status, + 'booster/database/readmodels', + 'Booster Database ReadModels', + 0 + ) + expect(databaseReadModels.details).to.be.deep.eq(details) +} + +class UserRole {} diff --git a/packages/framework-core/test/sensor/health/health-utils.test.ts b/packages/framework-core/test/sensor/health/health-utils.test.ts new file mode 100644 index 000000000..ec5bec1ab --- /dev/null +++ b/packages/framework-core/test/sensor/health/health-utils.test.ts @@ -0,0 +1,177 @@ +import { HealthIndicatorMetadata } from '@boostercloud/framework-types' +import 'mocha' +import { + childrenHealthProviders, + isEnabled, + metadataFromId, + parentId, + rootHealthProviders, + showChildren, +} from '../../../src/sensor/health/health-utils' +import { expect } from '../../expect' +import { BoosterHealthIndicator } from '../../../src/sensor' + +describe('Health utils', () => { + let root: HealthIndicatorMetadata + let rootChildren1: HealthIndicatorMetadata + let rootChildren2: HealthIndicatorMetadata + let rootChildren1Children1: HealthIndicatorMetadata + let rootChildren1Children2: HealthIndicatorMetadata + let healthProviders: Record + beforeEach(() => { + root = { + class: BoosterHealthIndicator, + healthIndicatorConfiguration: { + id: 'root', + name: 'root', + enabled: true, + details: true, + showChildren: true, + }, + } + rootChildren1 = { + class: BoosterHealthIndicator, + healthIndicatorConfiguration: { + id: 'root/rootChildren1', + name: 'root/rootChildren1', + enabled: true, + details: true, + showChildren: true, + }, + } + rootChildren2 = { + class: BoosterHealthIndicator, + healthIndicatorConfiguration: { + id: 'root/rootChildren2', + name: 'root/rootChildren2', + enabled: true, + details: true, + showChildren: true, + }, + } + rootChildren1Children1 = { + class: BoosterHealthIndicator, + healthIndicatorConfiguration: { + id: 'root/rootChildren1/rootChildren1Children1', + name: 'root/rootChildren1/rootChildren1Children1', + enabled: true, + details: true, + showChildren: true, + }, + } + rootChildren1Children2 = { + class: BoosterHealthIndicator, + healthIndicatorConfiguration: { + id: 'root/rootChildren1/rootChildren1Children2', + name: 'root/rootChildren1/rootChildren1Children2', + enabled: true, + details: true, + showChildren: true, + }, + } + healthProviders = { + root: root, + [rootChildren1.healthIndicatorConfiguration.id]: rootChildren1, + [rootChildren2.healthIndicatorConfiguration.id]: rootChildren2, + [rootChildren1Children1.healthIndicatorConfiguration.id]: rootChildren1Children1, + [rootChildren1Children2.healthIndicatorConfiguration.id]: rootChildren1Children2, + } + }) + it('isEnabled return true if all are true', () => { + expect(isEnabled(root, healthProviders)).to.be.true + expect(isEnabled(rootChildren1, healthProviders)).to.be.true + expect(isEnabled(rootChildren2, healthProviders)).to.be.true + expect(isEnabled(rootChildren1Children1, healthProviders)).to.be.true + expect(isEnabled(rootChildren1Children2, healthProviders)).to.be.true + }) + + it('isEnabled return false in a component but not in parents or siblings', () => { + healthProviders[rootChildren1Children1.healthIndicatorConfiguration.id].healthIndicatorConfiguration.enabled = false + expect(isEnabled(root, healthProviders)).to.be.true + + expect(isEnabled(rootChildren1, healthProviders)).to.be.true + expect(isEnabled(rootChildren1Children1, healthProviders)).to.be.false + expect(isEnabled(rootChildren1Children2, healthProviders)).to.be.true + + expect(isEnabled(rootChildren2, healthProviders)).to.be.true + }) + + it('isEnabled return false in a component and all the children but not siblings', () => { + healthProviders[rootChildren1.healthIndicatorConfiguration.id].healthIndicatorConfiguration.enabled = false + expect(isEnabled(root, healthProviders)).to.be.true + + expect(isEnabled(rootChildren1, healthProviders)).to.be.false + expect(isEnabled(rootChildren1Children1, healthProviders)).to.be.false + expect(isEnabled(rootChildren1Children2, healthProviders)).to.be.false + + expect(isEnabled(rootChildren2, healthProviders)).to.be.true + }) + + it('showChildren return true if all are true', () => { + expect(showChildren(root, healthProviders)).to.be.true + expect(showChildren(rootChildren1, healthProviders)).to.be.true + expect(showChildren(rootChildren2, healthProviders)).to.be.true + expect(showChildren(rootChildren1Children1, healthProviders)).to.be.true + expect(showChildren(rootChildren1Children2, healthProviders)).to.be.true + }) + + it('showChildren return false in a component but not in parents or siblings', () => { + healthProviders[rootChildren1Children1.healthIndicatorConfiguration.id].healthIndicatorConfiguration.showChildren = + false + expect(showChildren(root, healthProviders)).to.be.true + + expect(showChildren(rootChildren1, healthProviders)).to.be.true + expect(showChildren(rootChildren1Children1, healthProviders)).to.be.false + expect(showChildren(rootChildren1Children2, healthProviders)).to.be.true + + expect(showChildren(rootChildren2, healthProviders)).to.be.true + }) + + it('showChildren return false in a component and all the children but not siblings', () => { + healthProviders[rootChildren1.healthIndicatorConfiguration.id].healthIndicatorConfiguration.showChildren = false + expect(showChildren(root, healthProviders)).to.be.true + + expect(showChildren(rootChildren1, healthProviders)).to.be.false + expect(showChildren(rootChildren1Children1, healthProviders)).to.be.false + expect(showChildren(rootChildren1Children2, healthProviders)).to.be.false + + expect(showChildren(rootChildren2, healthProviders)).to.be.true + }) + + it('metadataFromId', () => { + expect(() => metadataFromId(healthProviders, '')).to.throw('Unexpected HealthProvider id ') + expect(() => metadataFromId(healthProviders, 'xxx')).to.throw('Unexpected HealthProvider id ') + expect(metadataFromId(healthProviders, 'root')).to.be.deep.equal(root) + expect(metadataFromId(healthProviders, 'root/rootChildren1')).to.be.deep.equal(rootChildren1) + expect(metadataFromId(healthProviders, 'root/rootChildren2')).to.be.deep.equal(rootChildren2) + expect(metadataFromId(healthProviders, 'root/rootChildren1/rootChildren1Children1')).to.be.deep.equal( + rootChildren1Children1 + ) + expect(metadataFromId(healthProviders, 'root/rootChildren1/rootChildren1Children2')).to.be.deep.equal( + rootChildren1Children2 + ) + }) + + it('parentId', () => { + expect(parentId(root)).to.be.eq('') + expect(parentId(rootChildren1)).to.be.eq('root') + expect(parentId(rootChildren2)).to.be.eq('root') + expect(parentId(rootChildren1Children1)).to.be.eq('root/rootChildren1') + expect(parentId(rootChildren1Children2)).to.be.eq('root/rootChildren1') + }) + + it('rootHealthProviders', () => { + expect(rootHealthProviders(healthProviders)).to.be.deep.equal([root]) + }) + + it('childrenHealthProviders', () => { + expect(childrenHealthProviders(root, healthProviders)).to.be.deep.equal([rootChildren1, rootChildren2]) + expect(childrenHealthProviders(rootChildren1, healthProviders)).to.be.deep.equal([ + rootChildren1Children1, + rootChildren1Children2, + ]) + expect(childrenHealthProviders(rootChildren1Children1, healthProviders)).to.be.deep.equal([]) + expect(childrenHealthProviders(rootChildren1Children2, healthProviders)).to.be.deep.equal([]) + expect(childrenHealthProviders(rootChildren2, healthProviders)).to.be.deep.equal([]) + }) +}) diff --git a/packages/framework-integration-tests/integration/fixtures/cart-demo/src/index.ts b/packages/framework-integration-tests/integration/fixtures/cart-demo/src/index.ts index 9b586b9f3..8e660a0e9 100644 --- a/packages/framework-integration-tests/integration/fixtures/cart-demo/src/index.ts +++ b/packages/framework-integration-tests/integration/fixtures/cart-demo/src/index.ts @@ -4,6 +4,7 @@ export { boosterEventDispatcher, boosterServeGraphQL, boosterNotifySubscribers, + boosterHealth, boosterTriggerScheduledCommand, boosterRocketDispatcher, } from '@boostercloud/framework-core' diff --git a/packages/framework-integration-tests/integration/provider-unaware/end-to-end/health.integration.ts b/packages/framework-integration-tests/integration/provider-unaware/end-to-end/health.integration.ts new file mode 100644 index 000000000..697cbf674 --- /dev/null +++ b/packages/framework-integration-tests/integration/provider-unaware/end-to-end/health.integration.ts @@ -0,0 +1,169 @@ +import { expect } from '../../helper/expect' +import { request } from '@boostercloud/framework-common-helpers' +import { applicationUnderTest } from './setup' +import { before } from 'mocha' + +describe('Health end-to-end tests', () => { + if (process.env.TESTED_PROVIDER === 'AWS') { + console.log('****************** Warning **********************') + console.log('AWS provider does not support sensor health so these tests are skipped for AWS') + console.log('*************************************************') + return + } + + let url = '' + before(async () => { + url = applicationUnderTest.http.getHealthUrl() + }) + + it('root health returns all indicators', async () => { + const jsonResult = await getHealth(url) + + const boosterResult = jsonResult.find((element: any) => element.id === 'booster') + expectBooster(boosterResult) + const boosterFunction = boosterResult.components.find((element: any) => element.id === 'booster/function') + expectBoosterFunction(boosterFunction) + const boosterDatabase = boosterResult.components.find((element: any) => element.id === 'booster/database') + expectBoosterDatabase(boosterDatabase) + const databaseEvents = boosterDatabase.components.find((element: any) => element.id === 'booster/database/events') + expectDatabaseEvents(databaseEvents) + const databaseReadModels = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/readmodels' + ) + expectDatabaseReadModels(databaseReadModels) + const myApplicationDatabase = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/myApplication' + ) + expectApplicationAddDatabase(myApplicationDatabase) + const myApplication2Database = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/myApplication2' + ) + expectApplication2AddDatabase(myApplication2Database) + + const appResult = jsonResult.find((element: any) => element.id === 'myApplication') + expectApplication(appResult) + const appChildResult = appResult.components.find((element: any) => element.id === 'myApplication/child') + expectApplicationChild(appChildResult) + }) + + it('function health returns the indicator', async () => { + const boosterFunction = (await getHealth(url, 'function'))[0] + expectBoosterFunction(boosterFunction) + }) + + it('database health returns the indicator', async () => { + const boosterDatabase = (await getHealth(url, 'database'))[0] + expectBoosterDatabase(boosterDatabase) + const databaseEvents = boosterDatabase.components.find((element: any) => element.id === 'booster/database/events') + expectDatabaseEvents(databaseEvents) + const databaseReadModels = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/readmodels' + ) + expectDatabaseReadModels(databaseReadModels) + const myApplicationDatabase = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/myApplication' + ) + expectApplicationAddDatabase(myApplicationDatabase) + const myApplication2Database = boosterDatabase.components.find( + (element: any) => element.id === 'booster/database/myApplication2' + ) + expectApplication2AddDatabase(myApplication2Database) + }) + + it('events database health returns the indicator', async () => { + const databaseEvents = (await getHealth(url, 'database/events'))[0] + expectDatabaseEvents(databaseEvents) + }) + + it('readmodels database health returns the indicator', async () => { + const databaseReadModels = (await getHealth(url, 'database/readmodels'))[0] + expectDatabaseReadModels(databaseReadModels) + }) +}) + +function expectBooster(boosterResult: any): void { + expect(boosterResult.id).to.be.eq('booster') + expect(boosterResult.status).to.be.eq('UP') + expect(boosterResult.name).to.be.eq('Booster') + expect(boosterResult.details.boosterVersion.length).to.be.gt(0) + expect(boosterResult.components.length).to.be.eq(2) +} + +function expectBoosterFunction(boosterFunction: any) { + expect(boosterFunction.id).to.be.eq('booster/function') + expect(boosterFunction.status).to.be.eq('UP') + expect(boosterFunction.name).to.be.eq('Booster Function') + expect(boosterFunction.details.cpus.length).to.be.gt(0) + expect(boosterFunction.details.cpus[0].timesPercentages.length).to.be.gt(0) + expect(boosterFunction.details.memory.totalBytes).to.be.gt(0) + expect(boosterFunction.details.memory.freeBytes).to.be.gt(0) + expect((boosterFunction.details.graphQL_url as string).endsWith('/graphql')).to.be.true + expect(boosterFunction.components).to.be.undefined +} + +function expectBoosterDatabase(boosterDatabase: any): void { + expect(boosterDatabase.id).to.be.eq('booster/database') + expect(boosterDatabase.status).to.be.eq('UP') + expect(boosterDatabase.name).to.be.eq('Booster Database') + expect(boosterDatabase.details).to.not.be.undefined + expect(boosterDatabase.components.length).to.be.eq(4) +} + +function expectDatabaseEvents(databaseEvent: any): void { + expect(databaseEvent.id).to.be.eq('booster/database/events') + expect(databaseEvent.status).to.be.eq('UP') + expect(databaseEvent.name).to.be.eq('Booster Database Events') + expect(databaseEvent.details).to.not.be.undefined + expect(databaseEvent.components).to.be.undefined +} + +function expectDatabaseReadModels(databaseReadModels: any): void { + expect(databaseReadModels.id).to.be.eq('booster/database/readmodels') + expect(databaseReadModels.status).to.be.eq('UP') + expect(databaseReadModels.name).to.be.eq('Booster Database ReadModels') + expect(databaseReadModels.details).to.not.be.undefined + expect(databaseReadModels.components).to.be.undefined +} + +function expectApplicationAddDatabase(applicationDatabase: any): void { + expect(applicationDatabase.id).to.be.eq('booster/database/myApplication') + expect(applicationDatabase.status).to.be.eq('UNKNOWN') + expect(applicationDatabase.name).to.be.eq('Indicator added to the Booster Database indicator through My Application') + expect(applicationDatabase.details).to.be.undefined + expect(applicationDatabase.components).to.be.undefined +} + +function expectApplication2AddDatabase(databaseApplication2: any): void { + expect(databaseApplication2.id).to.be.eq('booster/database/myApplication2') + expect(databaseApplication2.status).to.be.eq('UNKNOWN') + expect(databaseApplication2.name).to.be.eq( + 'A second indicator added to the Booster Database indicator through My Application' + ) + expect(databaseApplication2.details).to.be.undefined + expect(databaseApplication2.components).to.be.undefined +} + +function expectApplication(boosterResult: any): void { + expect(boosterResult.id).to.be.eq('myApplication') + expect(boosterResult.status).to.be.eq('UP') + expect(boosterResult.name).to.be.eq('my-application') + expect(boosterResult.details).to.be.undefined + expect(boosterResult.components.length).to.be.eq(1) +} + +function expectApplicationChild(boosterResult: any): void { + expect(boosterResult.id).to.be.eq('myApplication/child') + expect(boosterResult.status).to.be.eq('OUT_OF_SERVICE') + expect(boosterResult.name).to.be.eq('My Application child') + expect(boosterResult.details).to.be.undefined + expect(boosterResult.components).to.be.undefined +} + +async function getHealth(url: string, componentUrl?: string): Promise { + const path = componentUrl ? `${url}booster/${componentUrl}` : url + console.log(path) + const result = await request(path) + expect(result).to.not.be.undefined + expect(result.status).to.be.eq(200) + return JSON.parse(result.body as any) +} diff --git a/packages/framework-integration-tests/src/config/config.ts b/packages/framework-integration-tests/src/config/config.ts index 6d71bc55a..2941eee24 100644 --- a/packages/framework-integration-tests/src/config/config.ts +++ b/packages/framework-integration-tests/src/config/config.ts @@ -25,6 +25,12 @@ function configureInvocationsHandler(config: BoosterConfig) { } } +function configureBoosterSensorHealth(config: BoosterConfig) { + Object.values(config.sensorConfiguration.health.booster).forEach((indicator) => { + indicator.enabled = true + }) +} + Booster.configure('local', (config: BoosterConfig): void => { config.appName = 'my-store' config.providerPackage = '@boostercloud/framework-provider-local' @@ -44,6 +50,7 @@ Booster.configure('local', (config: BoosterConfig): void => { ] configureInvocationsHandler(config) configureLogger(config) + configureBoosterSensorHealth(config) }) Booster.configure('development', (config: BoosterConfig): void => { @@ -51,6 +58,7 @@ Booster.configure('development', (config: BoosterConfig): void => { config.providerPackage = '@boostercloud/framework-provider-aws' config.assets = ['assets', 'assetFile.txt'] configureInvocationsHandler(config) + configureBoosterSensorHealth(config) }) Booster.configure('production', (config: BoosterConfig): void => { @@ -80,6 +88,7 @@ Booster.configure('production', (config: BoosterConfig): void => { ), ] configureInvocationsHandler(config) + configureBoosterSensorHealth(config) }) Booster.configure('azure', (config: BoosterConfig): void => { @@ -110,4 +119,5 @@ Booster.configure('azure', (config: BoosterConfig): void => { ] configureInvocationsHandler(config) configureLogger(config) + configureBoosterSensorHealth(config) }) diff --git a/packages/framework-integration-tests/src/index.ts b/packages/framework-integration-tests/src/index.ts index 9b586b9f3..13a380f08 100644 --- a/packages/framework-integration-tests/src/index.ts +++ b/packages/framework-integration-tests/src/index.ts @@ -3,6 +3,7 @@ export { Booster, boosterEventDispatcher, boosterServeGraphQL, + boosterHealth, boosterNotifySubscribers, boosterTriggerScheduledCommand, boosterRocketDispatcher, diff --git a/packages/framework-integration-tests/src/sensor/health/application-add-booster-database-children-health-indicator.ts b/packages/framework-integration-tests/src/sensor/health/application-add-booster-database-children-health-indicator.ts new file mode 100644 index 000000000..8ef15c339 --- /dev/null +++ b/packages/framework-integration-tests/src/sensor/health/application-add-booster-database-children-health-indicator.ts @@ -0,0 +1,26 @@ +import { HealthSensor } from '@boostercloud/framework-core' +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, + BOOSTER_HEALTH_INDICATORS_IDS, +} from '@boostercloud/framework-types' + +@HealthSensor({ + id: `${BOOSTER_HEALTH_INDICATORS_IDS.DATABASE}/myApplication`, + name: 'Indicator added to the Booster Database indicator through My Application', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationAddBoosterDatabaseChildrenHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.UNKNOWN, + } as HealthIndicatorResult + } +} diff --git a/packages/framework-integration-tests/src/sensor/health/application-add2-booster-database-children-health-indicator.ts b/packages/framework-integration-tests/src/sensor/health/application-add2-booster-database-children-health-indicator.ts new file mode 100644 index 000000000..fb1889347 --- /dev/null +++ b/packages/framework-integration-tests/src/sensor/health/application-add2-booster-database-children-health-indicator.ts @@ -0,0 +1,26 @@ +import { HealthSensor } from '@boostercloud/framework-core' +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, + BOOSTER_HEALTH_INDICATORS_IDS, +} from '@boostercloud/framework-types' + +@HealthSensor({ + id: `${BOOSTER_HEALTH_INDICATORS_IDS.DATABASE}/myApplication2`, + name: 'A second indicator added to the Booster Database indicator through My Application', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationAdd2BoosterDatabaseChildrenHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.UNKNOWN, + } as HealthIndicatorResult + } +} diff --git a/packages/framework-integration-tests/src/sensor/health/application-child-health-indicator.ts b/packages/framework-integration-tests/src/sensor/health/application-child-health-indicator.ts new file mode 100644 index 000000000..ab7c99244 --- /dev/null +++ b/packages/framework-integration-tests/src/sensor/health/application-child-health-indicator.ts @@ -0,0 +1,25 @@ +import { HealthSensor } from '@boostercloud/framework-core' +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' + +@HealthSensor({ + id: 'myApplication/child', + name: 'My Application child', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationChildHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.OUT_OF_SERVICE, + } as HealthIndicatorResult + } +} diff --git a/packages/framework-integration-tests/src/sensor/health/application-health-indicator.ts b/packages/framework-integration-tests/src/sensor/health/application-health-indicator.ts new file mode 100644 index 000000000..d8f638734 --- /dev/null +++ b/packages/framework-integration-tests/src/sensor/health/application-health-indicator.ts @@ -0,0 +1,25 @@ +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' +import { HealthSensor } from '@boostercloud/framework-core' + +@HealthSensor({ + id: 'myApplication', + name: 'my-application', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.UP, + } as HealthIndicatorResult + } +} diff --git a/packages/framework-provider-aws-infrastructure/src/test-helper/aws-test-helper.ts b/packages/framework-provider-aws-infrastructure/src/test-helper/aws-test-helper.ts index 4cc2cee53..e208c624f 100644 --- a/packages/framework-provider-aws-infrastructure/src/test-helper/aws-test-helper.ts +++ b/packages/framework-provider-aws-infrastructure/src/test-helper/aws-test-helper.ts @@ -6,6 +6,7 @@ import { AWSQueries } from './aws-queries' interface ApplicationOutputs { graphqlURL: string websocketURL: string + healthURL: string } const cloudFormation = new CloudFormation() @@ -26,6 +27,7 @@ export class AWSTestHelper { { graphqlURL: await this.graphqlURL(stack), websocketURL: await this.websocketURL(stack), + healthURL: '', }, new AWSCounters(stackName), new AWSQueries(stackName) diff --git a/packages/framework-provider-aws/src/setup.ts b/packages/framework-provider-aws/src/setup.ts index e8426523a..a118de626 100644 --- a/packages/framework-provider-aws/src/setup.ts +++ b/packages/framework-provider-aws/src/setup.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { HasInfrastructure, ProviderLibrary, RocketDescriptor } from '@boostercloud/framework-types' +import { + BoosterConfig, + HasInfrastructure, + HealthEnvelope, + ProviderLibrary, + RocketDescriptor, +} from '@boostercloud/framework-types' import { DynamoDB } from 'aws-sdk' import { requestFailed, requestSucceeded } from './library/api-gateway-io' import { @@ -102,6 +108,18 @@ export const Provider = (rockets?: RocketDescriptor[]): ProviderLibrary => { rockets: { rawToEnvelopes: rawRocketInputToEnvelope, }, + sensor: { + databaseEventsHealthDetails: async (config: BoosterConfig): Promise => notImplementedResult(), + databaseReadModelsHealthDetails: async (config: BoosterConfig): Promise => notImplementedResult(), + isDatabaseEventUp: async (config: BoosterConfig): Promise => notImplementedResult(), + areDatabaseReadModelsUp: async (config: BoosterConfig): Promise => notImplementedResult(), + databaseUrls: async (config: BoosterConfig): Promise> => notImplementedResult(), + isGraphQLFunctionUp: async (config: BoosterConfig): Promise => notImplementedResult(), + graphQLFunctionUrl: async (config: BoosterConfig): Promise => notImplementedResult(), + rawRequestToHealthEnvelope: (rawRequest: unknown): HealthEnvelope => { + throw new Error('Not implemented') + }, + }, // ProviderInfrastructureGetter infrastructure: () => { const infrastructurePackageName = require('../package.json').name + '-infrastructure' @@ -121,4 +139,8 @@ export const Provider = (rockets?: RocketDescriptor[]): ProviderLibrary => { } } +function notImplementedResult() { + return Promise.reject('Not implemented') +} + export * from './constants' diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/functions/sensor-health-function.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/functions/sensor-health-function.ts new file mode 100644 index 000000000..d5ad32307 --- /dev/null +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/functions/sensor-health-function.ts @@ -0,0 +1,31 @@ +import { BoosterConfig } from '@boostercloud/framework-types' +import { HttpFunctionDefinition } from '../types/functionDefinition' + +export class SensorHealthFunction { + public constructor(readonly config: BoosterConfig) {} + + public getFunctionDefinition(): HttpFunctionDefinition { + return { + name: 'sensor-health', + config: { + bindings: [ + { + authLevel: 'anonymous', + type: 'httpTrigger', + direction: 'in', + name: 'rawRequest', + methods: ['get'], + route: 'sensor/health/{*url}', + }, + { + type: 'http', + direction: 'out', + name: '$return', + }, + ], + scriptFile: this.config.functionRelativePath, + entryPoint: this.config.sensorHealthHandler.split('.')[1], + }, + } + } +} diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/function-zip.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/function-zip.ts index 43982c817..be587590c 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/function-zip.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/helper/function-zip.ts @@ -15,6 +15,7 @@ import { User } from '@azure/arm-appservice' import { WebsocketConnectFunction } from '../functions/websocket-connect-function' import { WebsocketDisconnectFunction } from '../functions/websocket-disconnect-function' import { WebsocketMessagesFunction } from '../functions/websocket-messages-function' +import { SensorHealthFunction } from '../functions/sensor-health-function' export class FunctionZip { static async deployZip( @@ -78,7 +79,12 @@ export class FunctionZip { static buildAzureFunctions(config: BoosterConfig): Array { const graphqlFunctionDefinition = new GraphqlFunction(config).getFunctionDefinition() const eventHandlerFunctionDefinition = new EventHandlerFunction(config).getFunctionDefinition() - let featuresDefinitions = [graphqlFunctionDefinition, eventHandlerFunctionDefinition] + const sensorHealthHandlerFunctionDefinition = new SensorHealthFunction(config).getFunctionDefinition() + let featuresDefinitions = [ + graphqlFunctionDefinition, + eventHandlerFunctionDefinition, + sensorHealthHandlerFunctionDefinition, + ] if (config.enableSubscriptions) { const messagesFunctionDefinition = new WebsocketMessagesFunction(config).getFunctionDefinition() const disconnectFunctionDefinition = new WebsocketDisconnectFunction(config).getFunctionDefinition() diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts index 75b8c2c4b..e48d230aa 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/application-synth.ts @@ -24,6 +24,7 @@ import { AzurermProvider } from '@cdktf/provider-azurerm/lib/provider' import { TerraformOutputs } from './terraform-outputs' import { TerraformWebPubsubHub } from './terraform-web-pubsub-hub' import { TerraformWebPubSubExtensionKey } from './terraform-web-pub-sub-extension-key' +import { TerraformApiManagementApiOperationSensorHealth } from './terraform-api-management-api-operation-sensor-health' export class ApplicationSynth { readonly config: BoosterConfig @@ -143,6 +144,15 @@ export class ApplicationSynth { 'graphql' ) + const sensorHealthApiManagementApiOperationResource = TerraformApiManagementApiOperationSensorHealth.build( + azurermProvider, + this.terraformStackResource, + resourceGroupResource, + apiManagementApiResource, + this.appPrefix, + 'sensor-health' + ) + const graphQLApiManagementApiOperationPolicyResource = TerraformApiManagementApiOperationPolicy.build( azurermProvider, this.terraformStackResource, @@ -154,6 +164,17 @@ export class ApplicationSynth { 'graphql' ) + const sensorHealthApiManagementApiOperationPolicyResource = TerraformApiManagementApiOperationPolicy.build( + azurermProvider, + this.terraformStackResource, + resourceGroupResource, + sensorHealthApiManagementApiOperationResource, + this.appPrefix, + this.config.environmentName, + functionAppResource, + 'sensor-health' + ) + let webPubSubHubResource if (webPubSubResource) { @@ -183,6 +204,7 @@ export class ApplicationSynth { this.appPrefix, resourceGroupResource, graphQLApiManagementApiOperationResource, + sensorHealthApiManagementApiOperationResource, hubName, webPubSubResource ) @@ -201,6 +223,8 @@ export class ApplicationSynth { apiManagementApi: apiManagementApiResource, graphQLApiManagementApiOperation: graphQLApiManagementApiOperationResource, graphQLApiManagementApiOperationPolicy: graphQLApiManagementApiOperationPolicyResource, + sensorHealthApiManagementApiOperation: sensorHealthApiManagementApiOperationResource, + sensorHealthApiManagementApiOperationPolicy: sensorHealthApiManagementApiOperationPolicyResource, cosmosdbDatabase: cosmosdbDatabaseResource, cosmosdbSqlDatabase: cosmosdbSqlDatabaseResource, containers: containersResource, diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-api-management-api-operation-sensor-health.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-api-management-api-operation-sensor-health.ts new file mode 100644 index 000000000..f556a1f60 --- /dev/null +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-api-management-api-operation-sensor-health.ts @@ -0,0 +1,37 @@ +import { TerraformStack } from 'cdktf' +import { apiManagementApi, apiManagementApiOperation, resourceGroup } from '@cdktf/provider-azurerm' +import { toTerraformName } from '../helper/utils' +import { AzurermProvider } from '@cdktf/provider-azurerm/lib/provider' + +export class TerraformApiManagementApiOperationSensorHealth { + static build( + providerResource: AzurermProvider, + terraformStackResource: TerraformStack, + group: resourceGroup.ResourceGroup, + apiManagementApiResource: apiManagementApi.ApiManagementApi, + appPrefix: string, + name: string + ): apiManagementApiOperation.ApiManagementApiOperation { + const idApiManagementApiOperation = toTerraformName(appPrefix, 'amash' + name[0]) + return new apiManagementApiOperation.ApiManagementApiOperation( + terraformStackResource, + idApiManagementApiOperation, + { + operationId: `${name}GET`, + apiName: apiManagementApiResource.name, + apiManagementName: apiManagementApiResource.apiManagementName, + resourceGroupName: group.name, + displayName: '/sensor/health', + method: 'GET', + urlTemplate: '/sensor/health/*', + description: '', + response: [ + { + statusCode: 200, + }, + ], + provider: providerResource, + } + ) + } +} diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-outputs.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-outputs.ts index f77bba797..05255d815 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-outputs.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/synth/terraform-outputs.ts @@ -9,6 +9,7 @@ export class TerraformOutputs { appPrefix: string, resourceGroupResource: resourceGroup.ResourceGroup, graphQLApiManagementApiOperationResource: apiManagementApiOperation.ApiManagementApiOperation, + sensorHealthApiManagementApiOperationResource: apiManagementApiOperation.ApiManagementApiOperation, hubName: string, webPubsubResource?: webPubsub.WebPubsub ): void { @@ -23,6 +24,10 @@ export class TerraformOutputs { value: baseUrl + graphQLApiManagementApiOperationResource.urlTemplate, description: 'The base URL for sending GraphQL mutations and queries', }) + new TerraformOutput(providerResource, 'sensorHealthURL', { + value: baseUrl + sensorHealthApiManagementApiOperationResource.urlTemplate, + description: 'The base URL for getting health information', + }) if (webPubsubResource) { new TerraformOutput(providerResource, 'websocketURL', { diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts index d5dcf4b6d..62464cd87 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/application-synth-stack.ts @@ -31,6 +31,10 @@ export interface ApplicationSynthStack { apiManagementApi: apiManagementApi.ApiManagementApi | undefined graphQLApiManagementApiOperation: apiManagementApiOperation.ApiManagementApiOperation | undefined graphQLApiManagementApiOperationPolicy: apiManagementApiOperationPolicy.ApiManagementApiOperationPolicy | undefined + sensorHealthApiManagementApiOperation: apiManagementApiOperation.ApiManagementApiOperation | undefined + sensorHealthApiManagementApiOperationPolicy: + | apiManagementApiOperationPolicy.ApiManagementApiOperationPolicy + | undefined cosmosdbDatabase: cosmosdbAccount.CosmosdbAccount | undefined cosmosdbSqlDatabase: cosmosdbSqlDatabase.CosmosdbSqlDatabase | undefined containers: Array | undefined diff --git a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/functionDefinition.ts b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/functionDefinition.ts index a558e9580..8aef49720 100644 --- a/packages/framework-provider-azure-infrastructure/src/infrastructure/types/functionDefinition.ts +++ b/packages/framework-provider-azure-infrastructure/src/infrastructure/types/functionDefinition.ts @@ -4,6 +4,12 @@ export interface Binding { direction: string } +export type HttpBinding = Binding & { + authLevel?: string + methods?: Array + route?: string +} + export type ScheduleBinding = Binding & { schedule: string } @@ -47,6 +53,8 @@ export type ScheduleFunctionDefinition = FunctionDefinition export type GraphQLFunctionDefinition = FunctionDefinition +export type HttpFunctionDefinition = FunctionDefinition + export type EventHandlerFunctionDefinition = FunctionDefinition export type SubscriptionsNotifierFunctionDefinition = FunctionDefinition diff --git a/packages/framework-provider-azure-infrastructure/src/test-helper/azure-test-helper.ts b/packages/framework-provider-azure-infrastructure/src/test-helper/azure-test-helper.ts index eaccb3717..bcb8a786f 100644 --- a/packages/framework-provider-azure-infrastructure/src/test-helper/azure-test-helper.ts +++ b/packages/framework-provider-azure-infrastructure/src/test-helper/azure-test-helper.ts @@ -7,6 +7,7 @@ import { AzureQueries } from './azure-queries' interface ApplicationOutputs { graphqlURL: string websocketURL: string + healthURL: string } export class AzureTestHelper { @@ -29,6 +30,7 @@ export class AzureTestHelper { { graphqlURL: await this.graphqlURL(resourceGroup), websocketURL: await this.websocketURL(resourceGroup, 'booster'), + healthURL: await this.healthURL(resourceGroup), }, new AzureCounters(appName, cosmosConnectionString), new AzureQueries(appName, cosmosConnectionString) @@ -62,12 +64,17 @@ export class AzureTestHelper { return mainDbConnection.connectionString } public static async graphqlURL(resourceGroup: ResourceGroup): Promise { - const environment = process.env.BOOSTER_ENV ?? 'azure' + const environment = process.env.BOOSTER_ENV ?? 'DEFAULT' const url = `https://${resourceGroup.name}apis.azure-api.net/${environment}/graphql` console.log(`service Url: ${url}`) return url } + public static async healthURL(resourceGroup: ResourceGroup): Promise { + const environment = process.env.BOOSTER_ENV ?? 'azure' + return `https://${resourceGroup.name}apis.azure-api.net/${environment}/sensor/health/` + } + private static async websocketURL(resourceGroup: ResourceGroup, hubName: string): Promise { return `wss://${resourceGroup.name}wps.webpubsub.azure.com:443/client/hubs/${hubName}` } diff --git a/packages/framework-provider-azure/src/index.ts b/packages/framework-provider-azure/src/index.ts index c82419231..54c6ac74a 100644 --- a/packages/framework-provider-azure/src/index.ts +++ b/packages/framework-provider-azure/src/index.ts @@ -33,6 +33,16 @@ import { storeConnectionData, } from './library/connections-adapter' import { rawRocketInputToEnvelope } from './library/rocket-adapter' +import { + areDatabaseReadModelsUp, + databaseUrl, + databaseEventsHealthDetails, + graphqlFunctionUrl, + isDatabaseEventUp, + isGraphQLFunctionUp, + rawRequestToSensorHealth, + databaseReadModelsHealthDetails, +} from './library/health-adapter' let cosmosClient: CosmosClient if (typeof process.env[environmentVarNames.cosmosDbConnectionString] === 'undefined') { @@ -95,6 +105,16 @@ export const Provider = (rockets?: RocketDescriptor[]): ProviderLibrary => ({ rockets: { rawToEnvelopes: rawRocketInputToEnvelope, }, + sensor: { + databaseEventsHealthDetails: databaseEventsHealthDetails.bind(null, cosmosClient), + databaseReadModelsHealthDetails: databaseReadModelsHealthDetails.bind(null, cosmosClient), + isDatabaseEventUp: isDatabaseEventUp.bind(null, cosmosClient), + areDatabaseReadModelsUp: areDatabaseReadModelsUp.bind(null, cosmosClient), + databaseUrls: databaseUrl.bind(null, cosmosClient), + graphQLFunctionUrl: graphqlFunctionUrl, + isGraphQLFunctionUp: isGraphQLFunctionUp, + rawRequestToHealthEnvelope: rawRequestToSensorHealth, + }, // ProviderInfrastructureGetter infrastructure: () => { const infrastructurePackageName = require('../package.json').name + '-infrastructure' diff --git a/packages/framework-provider-azure/src/library/health-adapter.ts b/packages/framework-provider-azure/src/library/health-adapter.ts new file mode 100644 index 000000000..1b87c3ebe --- /dev/null +++ b/packages/framework-provider-azure/src/library/health-adapter.ts @@ -0,0 +1,111 @@ +import { BoosterConfig, HealthEnvelope } from '@boostercloud/framework-types' +import { Container, CosmosClient } from '@azure/cosmos' +import { environmentVarNames } from '../constants' +import { Context } from '@azure/functions' +import { request } from '@boostercloud/framework-common-helpers' + +export async function databaseUrl(cosmosDb: CosmosClient, config: BoosterConfig): Promise> { + const database = cosmosDb.database(config.resourceNames.applicationStack) + return [database.url] +} + +export function getContainer(cosmosDb: CosmosClient, config: BoosterConfig, containerName: string): Container { + return cosmosDb.database(config.resourceNames.applicationStack).container(containerName) +} + +export async function isContainerUp( + cosmosDb: CosmosClient, + config: BoosterConfig, + containerName: string +): Promise { + const container = getContainer(cosmosDb, config, containerName) + const { resources } = await container.items.query('SELECT TOP 1 1 FROM c', { maxItemCount: -1 }).fetchAll() + return resources !== undefined +} + +export async function countAll(container: Container): Promise { + const { resources } = await container.items.query('SELECT VALUE COUNT(1) FROM c', { maxItemCount: -1 }).fetchAll() + return resources ? resources[0] : 0 +} + +export async function databaseEventsHealthDetails(cosmosDb: CosmosClient, config: BoosterConfig): Promise { + const container = getContainer(cosmosDb, config, config.resourceNames.eventsStore) + const url = container.url + const count = await countAll(container) + return { + url: url, + count: count, + } +} + +export async function graphqlFunctionUrl(): Promise { + try { + const basePath = process.env[environmentVarNames.restAPIURL] + return `${basePath}/graphql` + } catch (e) { + return '' + } +} + +export async function isDatabaseEventUp(cosmosDb: CosmosClient, config: BoosterConfig): Promise { + return await isContainerUp(cosmosDb, config, config.resourceNames.eventsStore) +} + +export async function areDatabaseReadModelsUp(cosmosDb: CosmosClient, config: BoosterConfig): Promise { + const promises = Object.values(config.readModels).map((readModel) => { + const name = readModel.class.name + const container = config.resourceNames.forReadModel(name) + return isContainerUp(cosmosDb, config, container) + }) + const containersUp = await Promise.all(promises) + return containersUp.every((isContainerUp) => isContainerUp) +} + +export async function isGraphQLFunctionUp(): Promise { + try { + const restAPIUrl = await graphqlFunctionUrl() + const response = await request(restAPIUrl, 'POST') + return response.status === 200 + } catch (e) { + return false + } +} + +export function rawRequestToSensorHealthComponentPath(rawRequest: Context): string { + const parameters = rawRequest.req?.url.replace(/^.*sensor\/health\/?/, '') + return parameters ?? '' +} + +export function rawRequestToSensorHealth(context: Context): HealthEnvelope { + const componentPath = rawRequestToSensorHealthComponentPath(context) + const requestID = context.executionContext.invocationId + return { + requestID: requestID, + context: { + request: { + headers: context.req?.headers, + body: context.req?.body, + }, + rawContext: context, + }, + componentPath: componentPath, + token: context.req?.headers?.authorization, + } +} + +export async function databaseReadModelsHealthDetails(cosmosDb: CosmosClient, config: BoosterConfig): Promise { + const readModels = Object.values(config.readModels) + const result: Array = [] + for (const readModel of readModels) { + const name = readModel.class.name + const containerName = config.resourceNames.forReadModel(name) + const container = getContainer(cosmosDb, config, containerName) + const url: string = container.url + const count: number = await countAll(container) + result.push({ + url: url, + count: count, + }) + } + return result +} diff --git a/packages/framework-provider-local-infrastructure/src/controllers/health-controller.ts b/packages/framework-provider-local-infrastructure/src/controllers/health-controller.ts new file mode 100644 index 000000000..3fe206dc8 --- /dev/null +++ b/packages/framework-provider-local-infrastructure/src/controllers/health-controller.ts @@ -0,0 +1,27 @@ +import * as express from 'express' +import { HttpCodes, requestFailed } from '../http' +import { HealthService } from '@boostercloud/framework-provider-local' + +export class HealthController { + public router: express.Router = express.Router() + constructor(readonly healthService: HealthService) { + this.router.get('/*', this.handleHealth.bind(this)) + } + + public async handleHealth(req: express.Request, res: express.Response, next: express.NextFunction): Promise { + try { + const response = await this.healthService.handleHealthRequest(req) + if (response.status === 'success') { + res.status(HttpCodes.Ok).json(response.result) + } else { + res.status(response.code).json({ + title: response.title, + reason: response.message, + }) + } + } catch (e) { + await requestFailed(e, res) + next(e) + } + } +} diff --git a/packages/framework-provider-local-infrastructure/src/index.ts b/packages/framework-provider-local-infrastructure/src/index.ts index 9a75d953c..77e4e1b23 100644 --- a/packages/framework-provider-local-infrastructure/src/index.ts +++ b/packages/framework-provider-local-infrastructure/src/index.ts @@ -1,5 +1,5 @@ import * as express from 'express' -import { GraphQLService } from '@boostercloud/framework-provider-local' +import { GraphQLService, HealthService } from '@boostercloud/framework-provider-local' import { BoosterConfig, ProviderInfrastructure, RocketDescriptor, UserApp } from '@boostercloud/framework-types' import * as path from 'path' import { requestFailed } from './http' @@ -8,6 +8,8 @@ import * as cors from 'cors' import { configureScheduler } from './scheduler' import { RocketLoader } from '@boostercloud/framework-common-helpers' import { InfrastructureRocket } from './infrastructure-rocket' +import { HealthController } from './controllers/health-controller' +import * as process from 'process' export * from './test-helper/local-test-helper' export * from './infrastructure-rocket' @@ -45,6 +47,8 @@ export const Infrastructure = (rocketDescriptors?: RocketDescriptor[]): Provider const router = express.Router() const userProject = require(path.join(process.cwd(), 'dist', 'index.js')) const graphQLService = new GraphQLService(userProject as UserApp) + const healthService = new HealthService(userProject as UserApp) + router.use('/sensor/health', new HealthController(healthService).router) router.use('/graphql', new GraphQLController(graphQLService).router) if (rockets && rockets.length > 0) { rockets.forEach((rocket) => { diff --git a/packages/framework-provider-local-infrastructure/src/test-helper/local-test-helper.ts b/packages/framework-provider-local-infrastructure/src/test-helper/local-test-helper.ts index 39db5b5f9..170a7d6ef 100644 --- a/packages/framework-provider-local-infrastructure/src/test-helper/local-test-helper.ts +++ b/packages/framework-provider-local-infrastructure/src/test-helper/local-test-helper.ts @@ -4,6 +4,7 @@ import { LocalCounters } from './local-counters' interface ApplicationOutputs { graphqlURL: string websocketURL: string + healthURL: string } export class LocalTestHelper { @@ -19,6 +20,7 @@ export class LocalTestHelper { { graphqlURL: await this.graphqlURL(), websocketURL: await this.websocketURL(), + healthURL: await this.healthURL(), }, new LocalCounters(`${appName}-app`), new LocalQueries() @@ -34,6 +36,10 @@ export class LocalTestHelper { return url } + private static async healthURL(): Promise { + return 'http://localhost:3000/sensor/health/' + } + private static async websocketURL(): Promise { const url = 'ws://localhost:65529/websocket' return url diff --git a/packages/framework-provider-local/src/index.ts b/packages/framework-provider-local/src/index.ts index be6cae1a1..df800ca3a 100644 --- a/packages/framework-provider-local/src/index.ts +++ b/packages/framework-provider-local/src/index.ts @@ -37,9 +37,20 @@ import { WebSocketRegistry } from './services/web-socket-registry' import { connectionsDatabase, subscriptionDatabase } from './paths' import { rawRocketInputToEnvelope } from './library/rocket-adapter' import { WebSocketServerAdapter } from './library/web-socket-server-adapter' +import { + areDatabaseReadModelsUp, + databaseUrl, + databaseEventsHealthDetails, + graphqlFunctionUrl, + isDatabaseEventUp, + isGraphQLFunctionUp, + rawRequestToSensorHealth, + databaseReadModelsHealthDetails, +} from './library/health-adapter' export * from './paths' export * from './services' +import * as process from 'process' const eventRegistry = new EventRegistry() const readModelRegistry = new ReadModelRegistry() @@ -102,6 +113,16 @@ export const Provider = (rocketDescriptors?: RocketDescriptor[]): ProviderLibrar rockets: { rawToEnvelopes: rawRocketInputToEnvelope, }, + sensor: { + databaseEventsHealthDetails: databaseEventsHealthDetails.bind(null, eventRegistry), + databaseReadModelsHealthDetails: databaseReadModelsHealthDetails.bind(null, readModelRegistry), + isDatabaseEventUp: isDatabaseEventUp, + areDatabaseReadModelsUp: areDatabaseReadModelsUp, + databaseUrls: databaseUrl, + isGraphQLFunctionUp: isGraphQLFunctionUp, + graphQLFunctionUrl: graphqlFunctionUrl, + rawRequestToHealthEnvelope: rawRequestToSensorHealth, + }, // ProviderInfrastructureGetter infrastructure: () => { const infrastructurePackageName = require('../package.json').name + '-infrastructure' diff --git a/packages/framework-provider-local/src/library/health-adapter.ts b/packages/framework-provider-local/src/library/health-adapter.ts new file mode 100644 index 000000000..7005bb51c --- /dev/null +++ b/packages/framework-provider-local/src/library/health-adapter.ts @@ -0,0 +1,90 @@ +import * as DataStore from 'nedb' +import { EventRegistry, ReadModelRegistry } from '../services' +import { eventsDatabase, readModelsDatabase } from '../paths' +import { boosterLocalPort, HealthEnvelope, UUID } from '@boostercloud/framework-types' +import { existsSync } from 'fs' +import * as express from 'express' +import { request } from '@boostercloud/framework-common-helpers' + +export async function databaseUrl(): Promise> { + return [eventsDatabase, readModelsDatabase] +} + +export async function countAll(database: DataStore): Promise { + const count = await new Promise((resolve, reject) => { + database.count({}, (err, docs) => { + if (err) reject(err) + else resolve(docs) + }) + }) + return count ?? 0 +} + +export async function databaseEventsHealthDetails(eventRegistry: EventRegistry): Promise { + const count = await countAll(eventRegistry.events) + return { + file: eventsDatabase, + count: count, + } +} + +export async function graphqlFunctionUrl(): Promise { + try { + const port = boosterLocalPort() + return `http://localhost:${port}/graphql` + } catch (e) { + return '' + } +} + +export async function isDatabaseEventUp(): Promise { + return existsSync(eventsDatabase) +} + +export async function areDatabaseReadModelsUp(): Promise { + return existsSync(readModelsDatabase) +} + +export async function isGraphQLFunctionUp(): Promise { + try { + const url = await graphqlFunctionUrl() + const response = await request(url, 'POST') + return response.status === 200 + } catch (e) { + return false + } +} + +function rawRequestToSensorHealthComponentPath(rawRequest: express.Request): string { + const url = rawRequest?.url + if (url && url !== '/') { + return url.substring(1) + } + return '' +} + +export function rawRequestToSensorHealth(rawRequest: express.Request): HealthEnvelope { + const componentPath = rawRequestToSensorHealthComponentPath(rawRequest) + const requestID = UUID.generate() + const headers = rawRequest.headers + return { + requestID: requestID, + context: { + request: { + headers: headers, + body: {}, + }, + rawContext: rawRequest, + }, + componentPath: componentPath, + token: headers?.authorization, + } +} + +export async function databaseReadModelsHealthDetails(readModelRegistry: ReadModelRegistry): Promise { + const count = await countAll(readModelRegistry.readModels) + return { + file: readModelsDatabase, + count: count, + } +} diff --git a/packages/framework-provider-local/src/services/health-service.ts b/packages/framework-provider-local/src/services/health-service.ts new file mode 100644 index 000000000..ca2766a00 --- /dev/null +++ b/packages/framework-provider-local/src/services/health-service.ts @@ -0,0 +1,10 @@ +import * as express from 'express' +import { UserApp } from '@boostercloud/framework-types' + +export class HealthService { + public constructor(readonly userApp: UserApp) {} + + public async handleHealthRequest(request: express.Request): Promise { + return await this.userApp.boosterHealth(request) + } +} diff --git a/packages/framework-provider-local/src/services/index.ts b/packages/framework-provider-local/src/services/index.ts index 253ce2d19..f65f8c0a2 100644 --- a/packages/framework-provider-local/src/services/index.ts +++ b/packages/framework-provider-local/src/services/index.ts @@ -1,3 +1,4 @@ export * from './event-registry' export * from './read-model-registry' export * from './graphql-service' +export * from './health-service' diff --git a/packages/framework-provider-local/test/library/events-search-adapter.test.ts b/packages/framework-provider-local/test/library/events-search-adapter.test.ts index ab901b0d7..a34b8fb0f 100644 --- a/packages/framework-provider-local/test/library/events-search-adapter.test.ts +++ b/packages/framework-provider-local/test/library/events-search-adapter.test.ts @@ -1,6 +1,6 @@ import { BoosterConfig } from '@boostercloud/framework-types' import { createStubInstance, replace, restore, SinonStub, SinonStubbedInstance, stub } from 'sinon' -import { searchEntitiesIds } from '../../dist/library/events-search-adapter' +import { searchEntitiesIds } from '../../src/library/events-search-adapter' import { expect } from '../expect' import { WebSocketRegistry } from '../../src/services/web-socket-registry' diff --git a/packages/framework-types/src/concepts/authorizers.ts b/packages/framework-types/src/concepts/authorizers.ts index a44db0427..e9b67f6e8 100644 --- a/packages/framework-types/src/concepts/authorizers.ts +++ b/packages/framework-types/src/concepts/authorizers.ts @@ -1,10 +1,19 @@ -import { EventSearchRequest, ReadModelRequestEnvelope, UserEnvelope, CommandEnvelope, QueryEnvelope } from '../envelope' +import { + EventSearchRequest, + ReadModelRequestEnvelope, + UserEnvelope, + CommandEnvelope, + QueryEnvelope, + HealthEnvelope, +} from '../envelope' import { ReadModelInterface } from './read-model' export type CommandAuthorizer = (currentUser?: UserEnvelope, commandEnvelope?: CommandEnvelope) => Promise export type QueryAuthorizer = (currentUser?: UserEnvelope, queryEnvelope?: QueryEnvelope) => Promise +export type HealthAuthorizer = (currentUser?: UserEnvelope, healthEnvelope?: HealthEnvelope) => Promise + export type ReadModelAuthorizer = ( currentUser?: UserEnvelope, readModelRequestEnvelope?: ReadModelRequestEnvelope diff --git a/packages/framework-types/src/concepts/role.ts b/packages/framework-types/src/concepts/role.ts index 0e16aecc8..8ab4ac308 100644 --- a/packages/framework-types/src/concepts/role.ts +++ b/packages/framework-types/src/concepts/role.ts @@ -1,5 +1,11 @@ import { Class } from '../typelevel' -import { CommandAuthorizer, EventStreamAuthorizer, QueryAuthorizer, ReadModelAuthorizer } from './authorizers' +import { + CommandAuthorizer, + EventStreamAuthorizer, + HealthAuthorizer, + QueryAuthorizer, + ReadModelAuthorizer, +} from './authorizers' // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface RoleInterface {} @@ -22,6 +28,10 @@ export interface QueryRoleAccess { readonly authorize?: 'all' | Array> | QueryAuthorizer } +export interface HealthRoleAccess { + authorize?: 'all' | Array> | HealthAuthorizer +} + export interface ReadModelRoleAccess { readonly authorize?: 'all' | Array> | ReadModelAuthorizer } diff --git a/packages/framework-types/src/config.ts b/packages/framework-types/src/config.ts index 0f9f904b9..56a5678ba 100644 --- a/packages/framework-types/src/config.ts +++ b/packages/framework-types/src/config.ts @@ -20,7 +20,7 @@ import { ProviderLibrary } from './provider' import { Level } from './logger' import * as path from 'path' import { RocketDescriptor, RocketFunction } from './rockets' -import { Logger } from '.' +import { DEFAULT_SENSOR_HEALTH_BOOSTER_CONFIGURATIONS, HealthIndicatorMetadata, Logger, SensorConfiguration } from '.' import { TraceConfiguration } from './instrumentation/trace-types' /** @@ -46,6 +46,7 @@ export class BoosterConfig { public readonly codeRelativePath: string = 'dist' public readonly eventDispatcherHandler: string = path.join(this.codeRelativePath, 'index.boosterEventDispatcher') public readonly serveGraphQLHandler: string = path.join(this.codeRelativePath, 'index.boosterServeGraphQL') + public readonly sensorHealthHandler: string = path.join(this.codeRelativePath, 'index.boosterHealth') public readonly scheduledTaskHandler: string = path.join( this.codeRelativePath, 'index.boosterTriggerScheduledCommand' @@ -71,6 +72,15 @@ export class BoosterConfig { public readonly schemaMigrations: Record> = {} public readonly scheduledCommandHandlers: Record = {} public readonly dataMigrationHandlers: Record = {} + public userHealthIndicators: Record = {} + public readonly sensorConfiguration: SensorConfiguration = { + health: { + globalAuthorizer: { + authorize: 'all', + }, + booster: DEFAULT_SENSOR_HEALTH_BOOSTER_CONFIGURATIONS, + }, + } public globalErrorsHandler: GlobalErrorHandlerMetadata | undefined public enableSubscriptions = true public readonly nonExposedGraphQLMetadataKey: Record> = {} diff --git a/packages/framework-types/src/envelope.ts b/packages/framework-types/src/envelope.ts index db1bd18ab..5cba32c69 100644 --- a/packages/framework-types/src/envelope.ts +++ b/packages/framework-types/src/envelope.ts @@ -33,6 +33,11 @@ export interface CommandEnvelope extends TypedEnvelope { export type QueryEnvelope = CommandEnvelope +export interface HealthEnvelope extends Envelope { + componentPath: string + token?: string +} + export interface ScheduledCommandEnvelope extends Envelope { typeName: string } diff --git a/packages/framework-types/src/index.ts b/packages/framework-types/src/index.ts index 6ee6e764c..0f3dc7091 100644 --- a/packages/framework-types/src/index.ts +++ b/packages/framework-types/src/index.ts @@ -15,3 +15,5 @@ export * from './rockets' export * from './data-migration-parameters' export * from './super-kind' export * from './instrumentation/trace-types' +export * from './sensor/health-indicator-configuration' +export * from './internal-info' diff --git a/packages/framework-types/src/internal-info.ts b/packages/framework-types/src/internal-info.ts new file mode 100644 index 000000000..968c0b9ea --- /dev/null +++ b/packages/framework-types/src/internal-info.ts @@ -0,0 +1,3 @@ +export const BOOSTER_LOCAL_PORT = 'BOOSTER_INTERNAL_LOCAL_PORT' + +export const boosterLocalPort = (): string => process.env[BOOSTER_LOCAL_PORT] || '3000' diff --git a/packages/framework-types/src/provider.ts b/packages/framework-types/src/provider.ts index b5773977a..8f44b9082 100644 --- a/packages/framework-types/src/provider.ts +++ b/packages/framework-types/src/provider.ts @@ -15,6 +15,7 @@ import { ReadModelListResult, ScheduledCommandEnvelope, SubscriptionEnvelope, + HealthEnvelope, } from './envelope' import { FilterFor, SortFor } from './searcher' import { ReadOnlyNonEmptyArray } from './typelevel' @@ -29,12 +30,24 @@ export interface ProviderLibrary { scheduled: ScheduledCommandsLibrary infrastructure: () => ProviderInfrastructure rockets: ProviderRocketLibrary + sensor: ProviderSensorLibrary } export interface ProviderRocketLibrary { rawToEnvelopes(config: BoosterConfig, request: unknown): RocketEnvelope } +export interface ProviderSensorLibrary { + databaseEventsHealthDetails(config: BoosterConfig): Promise + databaseReadModelsHealthDetails(config: BoosterConfig): Promise + isDatabaseEventUp(config: BoosterConfig): Promise + areDatabaseReadModelsUp(config: BoosterConfig): Promise + databaseUrls(config: BoosterConfig): Promise> + isGraphQLFunctionUp(config: BoosterConfig): Promise + graphQLFunctionUrl(config: BoosterConfig): Promise + rawRequestToHealthEnvelope(rawRequest: unknown): HealthEnvelope +} + export interface ProviderEventsLibrary { /** * Converts raw events data into an array of EventEnvelope objects diff --git a/packages/framework-types/src/sensor/health-indicator-configuration.ts b/packages/framework-types/src/sensor/health-indicator-configuration.ts new file mode 100644 index 000000000..3556a8aa0 --- /dev/null +++ b/packages/framework-types/src/sensor/health-indicator-configuration.ts @@ -0,0 +1,85 @@ +import { HealthRoleAccess } from '../concepts' +import { BoosterConfig } from '../config' +import { Class } from '../typelevel' + +export enum HealthStatus { + UP = 'UP', // The component or subsystem is working as expected + DOWN = 'DOWN', // The component is not working + OUT_OF_SERVICE = 'OUT_OF_SERVICE', // The component is out of service temporarily + UNKNOWN = 'UNKNOWN', // The component state is unknown +} + +export interface HealthIndicatorResult { + status: HealthStatus + details?: { + [key: string]: unknown + } +} + +export interface HealthIndicatorsResult extends HealthIndicatorResult { + name: string + id: string + components?: Array +} + +export enum BOOSTER_HEALTH_INDICATORS_IDS { + ROOT = 'booster', + FUNCTION = 'booster/function', + DATABASE = 'booster/database', + DATABASE_EVENTS = 'booster/database/events', + DATABASE_READ_MODELS = 'booster/database/readmodels', +} + +export const DEFAULT_HEALTH_CONFIGURATION_BOOSTER: SensorBoosterHealthConfigurationDetails = { + enabled: false, + details: true, + showChildren: true, +} + +export const DEFAULT_SENSOR_HEALTH_BOOSTER_CONFIGURATIONS: Record< + BOOSTER_HEALTH_INDICATORS_IDS, + SensorBoosterHealthConfigurationDetails +> = { + [BOOSTER_HEALTH_INDICATORS_IDS.ROOT]: { ...DEFAULT_HEALTH_CONFIGURATION_BOOSTER }, + [BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION]: { ...DEFAULT_HEALTH_CONFIGURATION_BOOSTER }, + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE]: { ...DEFAULT_HEALTH_CONFIGURATION_BOOSTER }, + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS]: { ...DEFAULT_HEALTH_CONFIGURATION_BOOSTER }, + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS]: { ...DEFAULT_HEALTH_CONFIGURATION_BOOSTER }, +} + +export type SensorBoosterHealthConfigurationDetails = HealthIndicatorConfigurationBase + +export interface SensorBoosterHealthConfiguration { + globalAuthorizer: HealthRoleAccess + booster: { + [BOOSTER_HEALTH_INDICATORS_IDS.ROOT]: SensorBoosterHealthConfigurationDetails + [BOOSTER_HEALTH_INDICATORS_IDS.FUNCTION]: SensorBoosterHealthConfigurationDetails + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE]: SensorBoosterHealthConfigurationDetails + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_EVENTS]: SensorBoosterHealthConfigurationDetails + [BOOSTER_HEALTH_INDICATORS_IDS.DATABASE_READ_MODELS]: SensorBoosterHealthConfigurationDetails + } +} + +export interface SensorConfiguration { + health: SensorBoosterHealthConfiguration +} + +export interface HealthIndicatorInterface { + health: (config: BoosterConfig, healthIndicatorMetadata: HealthIndicatorMetadata) => Promise +} + +export interface HealthIndicatorConfigurationBase { + enabled: boolean + details: boolean + showChildren?: boolean +} + +export interface HealthIndicatorConfiguration extends HealthIndicatorConfigurationBase { + id: string + name: string +} + +export interface HealthIndicatorMetadata { + readonly class: Class + readonly healthIndicatorConfiguration: HealthIndicatorConfiguration +} diff --git a/packages/framework-types/src/user-app.ts b/packages/framework-types/src/user-app.ts index 2cc599f13..5e0e1fec6 100644 --- a/packages/framework-types/src/user-app.ts +++ b/packages/framework-types/src/user-app.ts @@ -11,4 +11,5 @@ export interface UserApp { boosterPreSignUpChecker(_: any): Promise boosterServeGraphQL(_: any): Promise boosterNotifySubscribers(_: any): Promise + boosterHealth(_: any): Promise } diff --git a/website/docs/10_going-deeper/data-migrations.md b/website/docs/10_going-deeper/data-migrations.md index 8abb6e176..be65e1259 100644 --- a/website/docs/10_going-deeper/data-migrations.md +++ b/website/docs/10_going-deeper/data-migrations.md @@ -164,3 +164,23 @@ export class CartIdDataMigrateV2 { "@boostercloud/metadata-booster": "0.30.2" }, ``` + +## Migrate to Booster version 1.19.0 + +Booster version 1.19.0 requires updating your index.ts file to export the `boosterHealth` method. If you have an index.ts file created from a previous Booster version, update it accordingly. Example: + +```typescript +import { Booster } from '@boostercloud/framework-core' +export { + Booster, + boosterEventDispatcher, + boosterServeGraphQL, + boosterHealth, + boosterNotifySubscribers, + boosterTriggerScheduledCommand, + boosterRocketDispatcher, +} from '@boostercloud/framework-core' + +Booster.start(__dirname) + +``` diff --git a/website/docs/10_going-deeper/health/sensor-health.md b/website/docs/10_going-deeper/health/sensor-health.md new file mode 100644 index 000000000..e1771e272 --- /dev/null +++ b/website/docs/10_going-deeper/health/sensor-health.md @@ -0,0 +1,397 @@ +--- +description: Learn how to get Booster health information +--- + +## Health + +The Health functionality allows users to easily monitor the health status of their applications. With this functionality, users can make GET requests to a specific endpoint and retrieve detailed information about the health and status of their application components. + +## Supported Providers +- Azure Provider +- Local Provider + +### Enabling Health Functionality + +To enable the Health functionality in your Booster application, follow these steps: + +1. Install or update to the latest version of the Booster framework, ensuring compatibility with the Health functionality. +2. Enable the Booster Health endpoints in your application's configuration file. Example configuration in config.ts: + +```typescript +Booster.configure('local', (config: BoosterConfig): void => { + config.appName = 'my-store' + config.providerPackage = '@boostercloud/framework-provider-local' + Object.values(config.sensorConfiguration.health.booster).forEach((indicator) => { + indicator.enabled = true + }) +}) +``` + +Or enable only the components you want: +```typescript +Booster.configure('local', (config: BoosterConfig): void => { + config.appName = 'my-store' + config.providerPackage = '@boostercloud/framework-provider-local' + const sensors = config.sensorConfiguration.health.booster + sensors[BOOSTER_HEALTH_INDICATORS_IDS.DATABASE].enabled = true +}) +``` + + +3. Optionally, implement health checks for your application components. Each component should provide a health method that performs the appropriate checks and returns a response indicating the health status. Example: + +```typescript +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' +import { HealthSensor } from '@boostercloud/framework-core' + +@HealthSensor({ + id: 'application', + name: 'my-application', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.UP, + } as HealthIndicatorResult + } +} +``` +4. A health check typically involves verifying the connectivity and status of the component, running any necessary tests, and returning an appropriate status code. +5. Start or restart your Booster application. The Health functionality will be available at the https://your-application-url/sensor/health/ endpoint URL. + + +### Health Endpoint + +The Health functionality provides a dedicated endpoint where users can make GET requests to retrieve the health status of their application. The endpoint URL is: https://your-application-url/sensor/health/ + +This endpoint will return all the enabled Booster and application components health status. To get specific component health status, add the component status to the url. For example, to get the events status use: https://your-application-url/sensor/health/booster/database/events + +#### Available endpoints + +Booster provides the following endpoints to retrieve the enabled components: + +* https://your-application-url/sensor/health/: All the components status +* https://your-application-url/sensor/health/booster: Booster status +* https://your-application-url/sensor/health/booster/database: Database status +* https://your-application-url/sensor/health/booster/database/events: Events status +* https://your-application-url/sensor/health/booster/database/readmodels: ReadModels status +* https://your-application-url/sensor/health/booster/function: Functions status +* https://your-application-url/sensor/health/your-component-id: User defined status +* https://your-application-url/sensor/health/your-component-id/your-component-child-id: User child component status + +Depending on the `showChildren` configuration, children components will be included or not. + +### Health Status Response + +Each component response will contain the following information: + +* status: The component or subsystem status +* name: component description +* id: string. unique component identifier. You can request a component status using the id in the url +* details: optional object. If `details` is true, specific details about this component. +* components: optional object. If `showChildren` is true, children components health status. + +Example: + +```json +[ + { + "status": "UP", + "details": { + "urls": [ + "dbs/my-store-app" + ] + }, + "name": "Booster Database", + "id": "booster/database", + "components": [ + { + "status": "UP", + "details": { + "url": "dbs/my-store-app/colls/my-store-app-events-store", + "count": 6 + }, + "name": "Booster Database Events", + "id": "booster/database/events" + }, + { + "status": "UP", + "details": [ + { + "url": "dbs/my-store-app/colls/my-store-app-ProductReadModel", + "count": 1 + } + ], + "name": "Booster Database ReadModels", + "id": "booster/database/readmodels" + } + ] + } +] +``` + +### Get specific component health information + +Use the `id` field to get specific component health information. Booster provides the following ids: + +* booster +* booster/function +* booster/database +* booster/database/events +* booster/database/readmodels + +You can provide new components: +```typescript +@HealthSensor({ + id: 'application', +}) +``` + +```typescript +@HealthSensor({ + id: 'application/child', +}) +``` + +Add your own components to Booster: + +```typescript +@HealthSensor({ + id: `${BOOSTER_HEALTH_INDICATORS_IDS.DATABASE}/extra`, +}) +``` + + +Or override Booster existing components with your own implementation: + +```typescript +@HealthSensor({ + id: BOOSTER_HEALTH_INDICATORS_IDS.DATABASE, +}) +``` + + +### Health configuration + +Health components are fully configurable, allowing you to display the information you want at any moment. + +Configuration options: +* enabled: If false, this indicator and the components of this indicator will be skipped +* details: If false, the indicator will not include the details +* showChildren: If false, this indicator will not include children components in the tree. + * Children components will be shown through children urls +* authorize: Authorize configuration. [See security documentation](https://docs.boosterframework.com/security/security) + +#### Booster components default configuration + +Booster sets the following default configuration for its own components: + +* enabled: false +* details: true +* showChildren: true + +Change this configuration using the `config.sensorConfiguration` object. This object provides: + +* config.sensorConfiguration.health.globalAuthorizer: Allow to define authorization configuration +* config.sensorConfiguration.health.booster: Allow to override default Booster components configuration + * config.sensorConfiguration.health.booster[BOOSTER_COMPONENT_ID].enabled + * config.sensorConfiguration.health.booster[BOOSTER_COMPONENT_ID].details + * config.sensorConfiguration.health.booster[BOOSTER_COMPONENT_ID].showChildren + + +#### User components configuration + +Use `@HealthSensor` parameters to configure user components. Example: + +```typescript +@HealthSensor({ + id: 'user', + name: 'my-application', + enabled: true, + details: true, + showChildren: true, +}) +``` + +### Create your own health endpoint + +Create your own health endpoint with a class annotated with `@HealthSensor` decorator. This class +should define a `health` method that returns a . Example: + +```typescript +import { + BoosterConfig, + HealthIndicatorResult, + HealthIndicatorMetadata, + HealthStatus, +} from '@boostercloud/framework-types' +import { HealthSensor } from '@boostercloud/framework-core' + +@HealthSensor({ + id: 'application', + name: 'my-application', + enabled: true, + details: true, + showChildren: true, +}) +export class ApplicationHealthIndicator { + public async health( + config: BoosterConfig, + healthIndicatorMetadata: HealthIndicatorMetadata + ): Promise { + return { + status: HealthStatus.UP, + } as HealthIndicatorResult + } +} +``` + +### Booster health endpoints + +#### booster +* status: UP if and only if graphql function is UP and events are UP +* details: + * boosterVersion: Booster version number + +#### booster/function +* status: UP if and only if graphql function is UP +* details: + * graphQL_url: GraphQL function url + * cpus: Information about each logical CPU core. + * cpu: + * model: Cpu model. Example: AMD EPYC 7763 64-Core Processor + * speed: cpu speed in MHz + * times: The number of milliseconds the CPU/core spent in (see iostat) + * user: CPU utilization that occurred while executing at the user level (application) + * nice: CPU utilization that occurred while executing at the user level with nice priority. + * sys: CPU utilization that occurred while executing at the system level (kernel). + * idle: CPU or CPUs were idle and the system did not have an outstanding disk I/O request. + * irq: CPU load system + * timesPercentages: For each times value, the percentage over the total times + * memory: + * totalBytes: the total amount of system memory in bytes as an integer. + * freeBytes: the amount of free system memory in bytes as an integer. + +#### booster/database + +* status: UP if and only if events are UP and Read Models are UP +* details: + * urls: Database urls + + +#### booster/database/events + +* status: UP if and only if events are UP +* details: + * **AZURE PROVIDER**: + * url: Events url + * count: number of rows + * **LOCAL PROVIDER**: + * file: event database file + * count: number of rows + + +#### booster/database/readmodels + +* status: UP if and only if Read Models are UP +* details: + * **AZURE PROVIDER**: + * For each Read Model: + * url: Event url + * count: number of rows + * **LOCAL PROVIDER**: + * file: Read Models database file + * count: number of total rows + +> **Note**: details will be included only if `details` is enabled + + +### Health status + +Available status are + +* UP: The component or subsystem is working as expected +* DOWN: The component is not working +* OUT_OF_SERVICE: The component is out of service temporarily +* UNKNOWN: The component state is unknown + +If a component throw an exception the status will be DOWN + + +### Securing health endpoints + +To configure the health endpoints authorization use `config.sensorConfiguration.health.globalAuthorizer`. + +Example: + +```typescript +config.sensorConfiguration.health.globalAuthorizer = { + authorize: 'all', +} +``` + +If the authorization process fails, the health endpoint will return a 401 error code + +### Example + +If all components are enable and showChildren is set to true: + +* A Request to https://your-application-url/sensor/health/ will return: + +```text +├── booster +│  ├── database +│    ├── events +│    └── readmodels +└  └── function +``` + +If the database component is disabled, the same url will return: + +```text +├── booster +└  └── function +``` + +If the request url is https://your-application-url/sensor/health/database, the component will not be returned + +```text +[Empty] +``` + +And the children components will be disabled too using direct url https://your-application-url/sensor/health/database/events + +```text +[Empty] +``` + +If database is enabled and showChildren is set to false and using https://your-application-url/sensor/health/ + +```text +├── booster +│  ├── database +│  └── function +``` + +using https://your-application-url/sensor/health/database, children will not be visible + +```text +└── database +``` + +but you can access to them using the component url https://your-application-url/sensor/health/database/events + +```text +└── events +``` diff --git a/website/docs/10_going-deeper/sensor.mdx b/website/docs/10_going-deeper/sensor.mdx new file mode 100644 index 000000000..8cc7e6a02 --- /dev/null +++ b/website/docs/10_going-deeper/sensor.mdx @@ -0,0 +1,9 @@ +--- +description: Learn how to get Booster sensor information +--- + +import DocCardList from '@theme/DocCardList' + +# Sensor + + diff --git a/website/sidebars.js b/website/sidebars.js index 7d3ff2aaf..d031a921b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -96,6 +96,17 @@ const sidebars = { 'going-deeper/rockets/rocket-webhook', ] }, + { + type: 'category', + label: 'Sensors', + link: { + type: 'doc', + id: 'going-deeper/sensor' + }, + items: [ + 'going-deeper/health/sensor-health', + ] + }, 'going-deeper/testing', 'going-deeper/data-migrations', 'going-deeper/touch-entities',