-
Notifications
You must be signed in to change notification settings - Fork 1
New: [AEA-6028] - Added shared code for CDK and Proxygen deployments #432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
280324f
547d5ed
75b99da
77e31b8
434dccc
c8b303d
37e1827
60e7a33
de588aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 | ||
| } | ||
| } | ||
| } | ||
| 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(".", "-")}` | ||
| } |
| 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" |
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| }) | ||
| } | ||
| } | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.