Skip to content
Open
2 changes: 1 addition & 1 deletion .github/scripts/check_ecr_image_scan_results.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ for i in {1..30}; do
--repository-name "$REPOSITORY_NAME" \
--image-id imageDigest="$IMAGE_DIGEST" \
--query 'imageScanStatus.status' \
--output text 2>/dev/null || echo "NONE")
--output text | grep -v "None" | head -n 1 2>/dev/null || echo "NONE"| grep -oE '^[^ ]+' | grep -v "None")

if [[ "$STATUS" == "COMPLETE" ]]; then
echo "ECR scan completed."
Expand Down
5,052 changes: 3,427 additions & 1,625 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/cdkConstructs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@
"private": false,
"type": "module",
"dependencies": {
"@aws-sdk/client-cloudformation": "^3.958.0",
"@aws-sdk/client-lambda": "^3.958.0",
"@aws-sdk/client-s3": "^3.958.0",
"aws-cdk": "^2.1100.3",
"aws-cdk-lib": "^2.234.1",
"cdk-nag": "^2.37.52",
"constructs": "^10.4.4"
"constructs": "^10.4.4",
"json-schema-to-ts": "^3.1.1"
},
"bugs": {
"url": "https://github.com/NHSDigital/eps-cdk-utils/issues"
Expand Down
60 changes: 60 additions & 0 deletions packages/cdkConstructs/src/apps/createApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
App,
Aspects,
Tags,
StackProps
} from "aws-cdk-lib"
import {AwsSolutionsChecks} from "cdk-nag"
import {getConfigFromEnvVar, getBooleanConfigFromEnvVar, calculateVersionedStackName} from "../config"

export interface StandardStackProps extends StackProps {
readonly stackName: string
readonly version: string
readonly commitId: string
readonly isPullRequest: boolean
}

export function createApp(
appName: string,
repoName: string,
driftDetectionGroup: string,
isStateless: boolean = true,
region: string = "eu-west-2"
): {app: App, props: StandardStackProps} {
let stackName = getConfigFromEnvVar("stackName")
const versionNumber = getConfigFromEnvVar("versionNumber")
const commitId = getConfigFromEnvVar("commitId")
const isPullRequest = getBooleanConfigFromEnvVar("isPullRequest")
let cfnDriftDetectionGroup = driftDetectionGroup
if (isPullRequest) {
cfnDriftDetectionGroup += "-pull-request"
}

const app = new App()

Aspects.of(app).add(new AwsSolutionsChecks({verbose: true}))

Tags.of(app).add("version", versionNumber)
Tags.of(app).add("commit", commitId)
Tags.of(app).add("stackName", stackName)
Tags.of(app).add("cdkApp", appName)
Tags.of(app).add("repo", repoName)
Tags.of(app).add("cfnDriftDetectionGroup", cfnDriftDetectionGroup)

if (isStateless && !isPullRequest) {
stackName = calculateVersionedStackName(stackName, versionNumber)
}

return {
app,
props: {
env: {
region
},
stackName,
version: versionNumber,
commitId,
isPullRequest
}
}
}
70 changes: 70 additions & 0 deletions packages/cdkConstructs/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {CloudFormationClient, ListExportsCommand, DescribeStacksCommand} from "@aws-sdk/client-cloudformation"
import {S3Client, HeadObjectCommand} from "@aws-sdk/client-s3"

export function getConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): string {
const value = process.env[prefix + varName]
if (!value) {
throw new Error(`Environment variable ${prefix}${varName} is not set`)
}
return value
}

export function getBooleanConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): boolean {
const value = getConfigFromEnvVar(varName, prefix)
return value.toLowerCase() === "true"
}

export function getNumberConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): number {
const value = getConfigFromEnvVar(varName, prefix)
return Number(value)
}

export async function getTrustStoreVersion(trustStoreFile: string, region: string = "eu-west-2"): Promise<string> {
const cfnClient = new CloudFormationClient({region})
const s3Client = new S3Client({region})
const describeStacksCommand = new DescribeStacksCommand({StackName: "account-resources"})
const response = await cfnClient.send(describeStacksCommand)
const trustStoreBucketArn = response.Stacks![0].Outputs!
.find(output => output.OutputKey === "TrustStoreBucket")!.OutputValue
const bucketName = trustStoreBucketArn!.split(":")[5]
const headObjectCommand = new HeadObjectCommand({Bucket: bucketName, Key: trustStoreFile})
const headObjectResponse = await s3Client.send(headObjectCommand)
return headObjectResponse.VersionId!
}

export async function getCloudFormationExports(region: string = "eu-west-2"): Promise<Record<string, string>> {
const cfnClient = new CloudFormationClient({region})
const listExportsCommand = new ListExportsCommand({})
const exports: Record<string, string> = {}
let nextToken: string | undefined = undefined

do {
const response = await cfnClient.send(listExportsCommand)
response.Exports?.forEach((exp) => {
if (exp.Name && exp.Value) {
exports[exp.Name] = exp.Value
}
})
nextToken = response.NextToken
listExportsCommand.input.NextToken = nextToken
} while (nextToken)

return exports
}

export function getCFConfigValue(exports: Record<string, string>, exportName: string): string {
const value = exports[exportName]
if (!value) {
throw new Error(`CloudFormation export ${exportName} not found`)
}
return value
}

export function getBooleanCFConfigValue(exports: Record<string, string>, exportName: string): boolean {
const value = getCFConfigValue(exports, exportName)
return value.toLowerCase() === "true"
}

export function calculateVersionedStackName(baseStackName: string, version: string): string {
return `${baseStackName}-${version.replaceAll(".", "-")}`
}
4 changes: 4 additions & 0 deletions packages/cdkConstructs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
// Export all constructs
export * from "./constructs/TypescriptLambdaFunction.js"
export * from "./apps/createApp.js"
export * from "./config/index.js"
export * from "./specifications/writeSchemas.js"
export * from "./specifications/deployApi.js"
158 changes: 158 additions & 0 deletions packages/cdkConstructs/src/specifications/deployApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@

import {LambdaClient, InvokeCommand} from "@aws-sdk/client-lambda"
import {getCFConfigValue, getCloudFormationExports, calculateVersionedStackName} from "../config"

export type ApiConfig = {
specification: string
apiName: string
version: string
apigeeEnvironment: string
isPullRequest: boolean
awsEnvironment: string
stackName: string
mtlsSecretName: string
clientCertExportName: string
clientPrivateKeyExportName: string
proxygenPrivateKeyExportName: string
proxygenKid: string
hiddenPaths: Array<string>
}

export async function deployApi(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how will this deal when we need to delete something from the spec in prod - https://github.com/NHSDigital/eps-prescription-status-update-api/blob/main/.github/scripts/deploy_api.sh#L105

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we also do this in fhir api

{
specification,
apiName,
version,
apigeeEnvironment,
isPullRequest,
awsEnvironment,
stackName,
mtlsSecretName,
clientCertExportName,
clientPrivateKeyExportName,
proxygenPrivateKeyExportName,
proxygenKid,
hiddenPaths
}: ApiConfig,
dryRun: boolean
): Promise<void> {
const lambda = new LambdaClient({})
async function invokeLambda(functionName: string, payload: unknown): Promise<void> {
if (dryRun) {
console.log(`Would invoke lambda ${functionName}`)
return
}

const invokeResult = await lambda.send(new InvokeCommand({
FunctionName: functionName,
Payload: Buffer.from(JSON.stringify(payload))
}))
const responsePayload = Buffer.from(invokeResult.Payload!).toString()
if (invokeResult.FunctionError) {
throw new Error(`Error calling lambda ${functionName}: ${responsePayload}`)
}
console.log(`Lambda ${functionName} invoked successfully. Response:`, responsePayload)
}

let instance = apiName
const spec = JSON.parse(specification)
if (isPullRequest) {
const pr_id = stackName.split("-").pop()
instance = `${apiName}-pr-${pr_id}`
spec.info.title = `[PR-${pr_id}] ${spec.info.title}`
spec["x-nhsd-apim"].monitoring = false
delete spec["x-nhsd-apim"].target.security.secret
} else {
stackName = calculateVersionedStackName(stackName, version)
spec["x-nhsd-apim"].target.security.secret = mtlsSecretName
}
spec.info.version = version
spec["x-nhsd-apim"].target.url = `https://${stackName}.${awsEnvironment}.eps.national.nhs.uk`

function replaceSchemeRefs(domain: string) {
const schemes = ["nhs-cis2-aal3", "nhs-login-p9", "app-level3", "app-level0"]
for (const scheme of schemes) {
if (spec.components.securitySchemes[scheme]) {
spec.components.securitySchemes[scheme] = {
"$ref": `https://${domain}/components/securitySchemes/${scheme}`
}
}
}
}
if (apigeeEnvironment === "prod") {
spec.servers = [ {url: `https://api.service.nhs.uk/${instance}`} ]
replaceSchemeRefs("proxygen.prod.api.platform.nhs.uk")
} else {
spec.servers = [ {url: `https://${apigeeEnvironment}.api.service.nhs.uk/${instance}`} ]
replaceSchemeRefs("proxygen.ptl.api.platform.nhs.uk")
}
if (apigeeEnvironment.includes("sandbox")) {
delete spec["x-nhsd-apim"]["target-attributes"] // Resolve issue with sandbox trying to look up app name
}

const exports = await getCloudFormationExports()
const clientCertArn = getCFConfigValue(exports, `account-resources:${clientCertExportName}`)
const clientPrivateKeyArn = getCFConfigValue(exports, `account-resources:${clientPrivateKeyExportName}`)
const proxygenPrivateKeyArn = getCFConfigValue(exports, `account-resources:${proxygenPrivateKeyExportName}`)

let put_secret_lambda = "lambda-resources-ProxygenPTLMTLSSecretPut"
let instance_put_lambda = "lambda-resources-ProxygenPTLInstancePut"
let spec_publish_lambda = "lambda-resources-ProxygenPTLSpecPublish"
if (/^(int|sandbox|prod)$/.test(apigeeEnvironment)) {
put_secret_lambda = "lambda-resources-ProxygenProdMTLSSecretPut"
instance_put_lambda = "lambda-resources-ProxygenProdInstancePut"
spec_publish_lambda = "lambda-resources-ProxygenProdSpecPublish"
}

// --- Store the secret used for mutual TLS ---
if (!isPullRequest) {
console.log("Store the secret used for mutual TLS to AWS using Proxygen proxy lambda")
await invokeLambda(put_secret_lambda, {
apiName,
environment: apigeeEnvironment,
secretName: mtlsSecretName,
secretKeyName: clientPrivateKeyArn,
secretCertName: clientCertArn,
kid: proxygenKid,
proxygenSecretName: proxygenPrivateKeyArn
})
}

// --- Deploy the API instance ---
console.log("Deploy the API instance using Proxygen proxy lambda")
await invokeLambda(instance_put_lambda, {
apiName,
environment: apigeeEnvironment,
specDefinition: spec,
instance,
kid: proxygenKid,
proxygenSecretName: proxygenPrivateKeyArn
})

// --- Publish the API spec to the catalogue ---
let spec_publish_env
if (apigeeEnvironment === "int") {
console.log("Deploy the API spec to prod catalogue as it is int environment")
spec.servers = [ {url: `https://sandbox.api.service.nhs.uk/${instance}`} ]
spec_publish_env = "prod"
} else if (apigeeEnvironment === "internal-dev" && !isPullRequest) {
console.log("Deploy the API spec to uat catalogue as it is internal-dev environment")
spec.servers = [ {url: `https://internal-dev-sandbox.api.service.nhs.uk/${instance}`} ]
spec_publish_env = "uat"
}
if (spec_publish_env) {
for (const path of hiddenPaths) {
if (spec.paths[path]) {
delete spec.paths[path]
}
}
await invokeLambda(spec_publish_lambda, {
apiName,
environment: spec_publish_env,
specDefinition: spec,
instance,
kid: proxygenKid,
proxygenSecretName: proxygenPrivateKeyArn
})
}
}
64 changes: 64 additions & 0 deletions packages/cdkConstructs/src/specifications/writeSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from "node:fs"
import path from "node:path"
import {JSONSchema} from "json-schema-to-ts"

function isNotJSONSchemaArray(schema: JSONSchema | ReadonlyArray<JSONSchema>): schema is JSONSchema {
return !Array.isArray(schema)
}

function collapseExamples(schema: JSONSchema): JSONSchema {
if (typeof schema !== "object" || schema === null) {
return schema
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {...schema}

if (Array.isArray(schema.examples) && schema.examples.length > 0) {
result.example = schema.examples[0]
delete result.examples
}

if (schema.items) {
if (isNotJSONSchemaArray(schema.items)) {
result.items = collapseExamples(schema.items)
} else {
result.items = schema.items.map(collapseExamples)
}
}

if (schema.properties) {
const properties: Record<string, JSONSchema> = {}
for (const key in schema.properties) {
if (Object.hasOwn(schema.properties, key)) {
properties[key] = collapseExamples(schema.properties[key])
}
}
result.properties = properties
}

return result
}

export function writeSchemas(
schemas: Record<string, JSONSchema>,
outputDir: string
): void {
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, {recursive: true})
}
for (const name in schemas) {
if (Object.hasOwn(schemas, name)) {
const schema = schemas[name]
const fileName = `${name}.json`
const filePath = path.join(outputDir, fileName)

try {
fs.writeFileSync(filePath, JSON.stringify(collapseExamples(schema), null, 2))
console.log(`Schema ${fileName} written successfully.`)
} catch (error) {
console.error(`Error writing schema ${fileName}:`, error)
}
}
}
}
Loading