Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 changes: 3 additions & 2 deletions .vscode/eps-cdk-utils.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
],
"settings": {
"files.exclude": {
"packages/": true,
"packages/": true,
},
"cSpell.words": [
"cSpell.words": [
"apigw",
"ASID",
"AWSKMS",
Expand Down Expand Up @@ -49,6 +49,7 @@
"pollable",
"powertools",
"Prosthetist",
"proxygen",
"querystring",
"reingest",
"reingested",
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"
147 changes: 147 additions & 0 deletions packages/cdkConstructs/src/specifications/deployApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {LambdaClient, InvokeCommand} from "@aws-sdk/client-lambda"
import {getCFConfigValue, getCloudFormationExports} from "../config"
import {fixSpec} from "./fixSpec"

export type ApiConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spec: any
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>
}

const lambda = new LambdaClient({})

async function invokeLambda(
dryRun: boolean,
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)
}

export async function deployApi(
{
spec,
apiName,
version,
apigeeEnvironment,
isPullRequest,
awsEnvironment,
stackName,
mtlsSecretName,
clientCertExportName,
clientPrivateKeyExportName,
proxygenPrivateKeyExportName,
proxygenKid,
hiddenPaths
}: ApiConfig,
blueGreen: boolean = true,
dryRun: boolean = false
): Promise<void> {
const instance = fixSpec({
spec,
apiName,
version,
apigeeEnvironment,
isPullRequest,
awsEnvironment,
stackName,
mtlsSecretName,
blueGreen
})

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"
}

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

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

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) {
delete spec.paths[path]
}
await invokeLambda(
dryRun,
spec_publish_lambda,
{
apiName,
environment: spec_publish_env,
specDefinition: spec,
instance,
kid: proxygenKid,
proxygenSecretName: proxygenPrivateKeyArn
}
)
}
}
Loading