diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 2f28d7fbd..0b97fdbbd 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,6 +37,7 @@ No requirements. | [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [letter\_status\_update](#module\_letter\_status\_update) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf index d6ff0cb43..8acebc8b8 100644 --- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf @@ -54,6 +54,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.get_letters.function_arn, module.patch_letter.function_arn, module.post_letters.function_arn, + module.get_status.function_arn, module.post_mi.function_arn ] } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index a6621a3d8..fec749a36 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -11,6 +11,7 @@ locals { GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn + GET_STATUS_LAMBDA_ARN = module.get_status.function_arn PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn POST_LETTERS_LAMBDA_ARN = module.post_letters.function_arn POST_MI_LAMBDA_ARN = module.post_mi.function_arn diff --git a/infrastructure/terraform/components/api/module_lambda_get_status.tf b/infrastructure/terraform/components/api/module_lambda_get_status.tf new file mode 100644 index 000000000..37e33d5f8 --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_get_status.tf @@ -0,0 +1,76 @@ +module "get_status" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + + function_name = "get_status" + description = "Healthcheck for service" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.get_status_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "api-handler/dist" + function_include_common = true + handler_function_name = "getStatus" + runtime = "nodejs22.x" + memory = 128 + timeout = 5 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + send_to_firehose = true + log_destination_arn = local.destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = merge(local.common_lambda_env_vars, {}) +} + +data "aws_iam_policy_document" "get_status_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, ## Requires shared kms module + ] + } + + statement { + sid = "AllowDynamoDBAccess" + effect = "Allow" + + actions = [ + "dynamodb:DescribeTable" + ] + + resources = [ + aws_dynamodb_table.letters.arn, + "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" + ] + } + + + statement { + sid = "S3ListAllMyBuckets" + actions = ["s3:ListAllMyBuckets"] + resources = ["arn:aws:s3:::*"] + } +} diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 76ed2dd1f..5d3337807 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -23,6 +23,34 @@ }, "openapi": "3.0.1", "paths": { + "/_status": { + "get": { + "operationId": "getStatusId", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Server error" + } + }, + "summary": "Healthcheck endpoint", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_STATUS_LAMBDA_ARN}/invocations" + } + } + }, "/letters": { "get": { "description": "Returns 200 OK with paginated letter ids.", diff --git a/internal/datastore/src/__test__/heathcheck.test.ts b/internal/datastore/src/__test__/heathcheck.test.ts new file mode 100644 index 000000000..ade1b1b18 --- /dev/null +++ b/internal/datastore/src/__test__/heathcheck.test.ts @@ -0,0 +1,38 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DBHealthcheck } from "../healthcheck"; +import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from "./db"; + +// Database tests can take longer, especially with setup and teardown +jest.setTimeout(30000); + +describe('DBHealthcheck', () => { + + let db: DBContext; + + beforeAll(async () => { + db = await setupDynamoDBContainer(); + }); + + beforeEach(async () => { + await createTables(db); + }); + + afterEach(async () => { + await deleteTables(db); + }); + + it('passes when the database is available', async () => { + const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); + await dbHealthCheck.check(); + }); + + it('fails when the database is unavailable', async () => { + const realFunction = db.docClient.send; + db.docClient.send = jest.fn().mockImplementation(() => { throw new Error('Failed to send')}); + + const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); + await expect(dbHealthCheck.check()).rejects.toThrow(); + + db.docClient.send = realFunction; + }); +}); diff --git a/internal/datastore/src/healthcheck.ts b/internal/datastore/src/healthcheck.ts new file mode 100644 index 000000000..39c91c619 --- /dev/null +++ b/internal/datastore/src/healthcheck.ts @@ -0,0 +1,13 @@ +import { DescribeTableCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { LetterRepositoryConfig } from "./letter-repository"; + +export class DBHealthcheck { + constructor(readonly ddbClient: DynamoDBDocumentClient, + readonly config: LetterRepositoryConfig) {} + + async check(): Promise { + await this.ddbClient.send(new DescribeTableCommand({ + TableName: this.config.lettersTableName})); + } +} diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 31f2b7547..5073399b2 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -2,4 +2,5 @@ export * from './types'; export * from './mi-repository'; export * from './letter-repository'; export * from './supplier-repository'; +export * from './healthcheck'; export * from './types'; diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index e56fe2c54..ceb2e294b 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -1,4 +1,3 @@ - import type { Deps } from '../deps'; describe('createDependenciesContainer', () => { @@ -40,6 +39,7 @@ describe('createDependenciesContainer', () => { jest.mock('@internal/datastore', () => ({ LetterRepository: jest.fn(), MIRepository: jest.fn(), + DBHealthcheck: jest.fn() })); // Env diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index 58739aba8..d0528492a 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -3,7 +3,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { SQSClient } from "@aws-sdk/client-sqs"; import pino from 'pino'; -import { LetterRepository, MIRepository } from '../../../../internal/datastore'; +import { LetterRepository, MIRepository, DBHealthcheck } from '@internal/datastore'; import { envVars, EnvVars } from "../config/env"; export type Deps = { @@ -11,6 +11,7 @@ export type Deps = { sqsClient: SQSClient; letterRepo: LetterRepository; miRepo: MIRepository; + dbHealthcheck: DBHealthcheck; logger: pino.Logger; env: EnvVars }; @@ -20,6 +21,7 @@ function createDocumentClient(): DynamoDBDocumentClient { return DynamoDBDocumentClient.from(ddbClient); } + function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository { const config = { @@ -30,6 +32,15 @@ function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepos return new LetterRepository(createDocumentClient(), log, config); } +function createDBHealthcheck(envVars: EnvVars): DBHealthcheck { + const config = { + lettersTableName: envVars.LETTERS_TABLE_NAME, + lettersTtlHours: envVars.LETTER_TTL_HOURS + }; + + return new DBHealthcheck(createDocumentClient(), config); +} + function createMIRepository(log: pino.Logger, envVars: EnvVars): MIRepository { const config = { @@ -49,6 +60,7 @@ export function createDependenciesContainer(): Deps { sqsClient: new SQSClient(), letterRepo: createLetterRepository(log, envVars), miRepo: createMIRepository(log, envVars), + dbHealthcheck: createDBHealthcheck(envVars), logger: log, env: envVars }; diff --git a/lambdas/api-handler/src/handlers/__tests__/get_status.test.ts b/lambdas/api-handler/src/handlers/__tests__/get_status.test.ts new file mode 100644 index 000000000..8fae2419e --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/get_status.test.ts @@ -0,0 +1,73 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { DBHealthcheck } from "@internal/datastore/src"; +import pino from "pino"; +import { Deps } from "../../config/deps"; +import { makeApiGwEvent } from "./utils/test-utils"; +import { mockDeep } from "jest-mock-extended"; +import { Context } from "aws-lambda"; +import { createGetStatusHandler } from "../get-status"; + +describe('API Lambda handler', () => { + it('passes if S3 and DynamoDB are available', async() => { + + const event = makeApiGwEvent({path: '/_status', + headers: undefined + }); + + const getLetterDataHandler = createGetStatusHandler(getMockedDeps()); + const result = await getLetterDataHandler(event, mockDeep(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ code: 200 }, null, 2) + }); + }); + + it('fails if S3 is unavailable', async() => { + const mockedDeps = getMockedDeps(); + mockedDeps.s3Client.send = jest.fn().mockRejectedValue(new Error('unexpected error')); + + const event = makeApiGwEvent({path: '/_status', + headers: undefined + }); + + const getLetterDataHandler = createGetStatusHandler(mockedDeps); + const result = await getLetterDataHandler(event, mockDeep(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ code: 500 }, null, 2) + }); + }); + + + it('fails if DynamoDB is unavailable', async() => { + const mockedDeps = getMockedDeps(); + mockedDeps.dbHealthcheck.check = jest.fn().mockRejectedValue(new Error('unexpected error')); + + const event = makeApiGwEvent({path: '/_status', + headers: {'Nhsd-Correlation-Id': 'correlationId'} + }); + + const getLetterDataHandler = createGetStatusHandler(mockedDeps); + const result = await getLetterDataHandler(event, mockDeep(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ code: 500 }, null, 2) + }); + }); + + + function getMockedDeps(): jest.Mocked { + return { + s3Client: { send: jest.fn()} as unknown as S3Client, + dbHealthcheck: {check: jest.fn()} as unknown as DBHealthcheck, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id' + } + } as Deps; + } +}); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 968758306..232e5adb8 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -47,17 +47,17 @@ describe('patchLetter API Handler', () => { }); const mockedDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 12960, - DOWNLOAD_URL_TTL_SECONDS: 60 - } as unknown as EnvVars - } as Deps; + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60 + } as unknown as EnvVars + } as Deps; it('returns 202 Accepted', async () => { const event = makeApiGwEvent({ diff --git a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts index d7d942e14..10897fb76 100644 --- a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts @@ -4,7 +4,7 @@ import { makeApiGwEvent } from "./utils/test-utils"; import { PostMIRequest, PostMIResponse } from "../../contracts/mi"; import * as miService from '../../services/mi-operations'; import pino from 'pino'; -import { MIRepository } from "../../../../../internal/datastore/src"; +import { MIRepository } from "@internal/datastore/src"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; import { createPostMIHandler } from "../post-mi"; diff --git a/lambdas/api-handler/src/handlers/get-status.ts b/lambdas/api-handler/src/handlers/get-status.ts new file mode 100644 index 000000000..81bbf9038 --- /dev/null +++ b/lambdas/api-handler/src/handlers/get-status.ts @@ -0,0 +1,38 @@ +import { APIGatewayProxyHandler } from "aws-lambda"; +import { Deps } from "../config/deps"; +import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3"; +import { mapErrorToResponse } from "../mappers/error-mapper"; + +export function createGetStatusHandler(deps: Deps): APIGatewayProxyHandler { + + return async(_) => { + + try { + await deps.dbHealthcheck.check(); + await s3HealthCheck(deps.s3Client); + + deps.logger.info({ + description: 'Healthcheck passed' + }); + + return { + statusCode: 200, + body: JSON.stringify({ code: 200 }, null, 2) + }; + } catch (error) { + deps.logger.error({ err: error }, 'Status endpoint error, services not available'); + return { + statusCode: 500, + body: JSON.stringify({ code: 500 }, null, 2) + }; + } + } +} + + +async function s3HealthCheck(s3Client: S3Client) { + const command: ListBucketsCommand = new ListBucketsCommand({ + MaxBuckets: 1 + }); + await s3Client.send(command); +} diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 010d44b1b..d657f746b 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -6,6 +6,7 @@ import { createPatchLetterHandler } from "./handlers/patch-letter"; import { createPostLettersHandler } from "./handlers/post-letters"; import { createLetterStatusUpdateHandler } from "./handlers/letter-status-update"; import { createPostMIHandler } from "./handlers/post-mi"; +import { createGetStatusHandler } from "./handlers/get-status"; const container = createDependenciesContainer(); @@ -17,3 +18,4 @@ export const letterStatusUpdate = createLetterStatusUpdateHandler(container); export const postLetters = createPostLettersHandler(container); export const postMI = createPostMIHandler(container); +export const getStatus = createGetStatusHandler(container); diff --git a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts index 8a2d2c772..f3c8491dd 100644 --- a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts @@ -49,7 +49,7 @@ describe("processError", () => { }); it("should map generic Error to InternalServerError response", () => { - const err = new Error("Something broke"); + const err = new Error("Low level error message"); const res = processError(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); @@ -58,7 +58,7 @@ describe("processError", () => { "errors": [ { "code": "NOTIFY_INTERNAL_SERVER_ERROR", - "detail": "Something broke", + "detail": "Unexpected error", "id": "correlationId", "links": { "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier" diff --git a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts index be1a55c15..e36555fe5 100644 --- a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts @@ -1,4 +1,4 @@ -import { MIBase } from "../../../../../internal/datastore/src"; +import { MIBase } from "@internal/datastore/src"; import { IncomingMI, PostMIRequest } from "../../contracts/mi"; import { mapToMI, mapToPostMIResponse } from "../mi-mapper"; diff --git a/lambdas/api-handler/src/mappers/error-mapper.ts b/lambdas/api-handler/src/mappers/error-mapper.ts index 180e0c8f9..d2fc98661 100644 --- a/lambdas/api-handler/src/mappers/error-mapper.ts +++ b/lambdas/api-handler/src/mappers/error-mapper.ts @@ -18,7 +18,7 @@ export function logAndMapToApiError(error: unknown, correlationId: string | unde return mapToApiError(ApiErrorCode.NotFound, error.detail, correlationId); } else if (error instanceof Error) { logger.error({ err: error }, `Internal server error correlationId=${correlationId}`); - return mapToApiError(ApiErrorCode.InternalServerError, error.message, correlationId); + return mapToApiError(ApiErrorCode.InternalServerError, "Unexpected error", correlationId); } else { logger.error({ err: error }, `Internal server error (non-Error thrown) correlationId=${correlationId}`); return mapToApiError(ApiErrorCode.InternalServerError, "Unexpected error", correlationId); diff --git a/lambdas/api-handler/src/mappers/mi-mapper.ts b/lambdas/api-handler/src/mappers/mi-mapper.ts index 83848e1a2..1dc980d5a 100644 --- a/lambdas/api-handler/src/mappers/mi-mapper.ts +++ b/lambdas/api-handler/src/mappers/mi-mapper.ts @@ -1,4 +1,4 @@ -import { MIBase } from "../../../../internal/datastore/src"; +import { MIBase } from "@internal/datastore/src"; import { IncomingMI, PostMIRequest as PostMIRequest, PostMIResponse, PostMIResponseSchema } from "../contracts/mi"; export function mapToMI(request: PostMIRequest, supplierId: string): IncomingMI { diff --git a/lambdas/api-handler/src/services/mi-operations.ts b/lambdas/api-handler/src/services/mi-operations.ts index 2c574b676..d1cc207c1 100644 --- a/lambdas/api-handler/src/services/mi-operations.ts +++ b/lambdas/api-handler/src/services/mi-operations.ts @@ -1,4 +1,4 @@ -import { MIRepository } from "../../../../internal/datastore/src/mi-repository"; +import { MIRepository } from "@internal/datastore/src/mi-repository"; import { IncomingMI, PostMIResponse } from "../contracts/mi"; import { mapToPostMIResponse } from "../mappers/mi-mapper";