From 324374f925376ed9640a92895596a4e0268ee9cb Mon Sep 17 00:00:00 2001 From: AlexTech314 Date: Tue, 24 Dec 2024 23:40:39 -0500 Subject: [PATCH] fix: adding tag ref in CustomResource --- .projen/deps.json | 2 +- .projenrc.ts | 4 +- API.md | 73 +++++++------ isComplete/isComplete.js | 159 ++++++++++++++++------------ package-lock.json | 9 +- package.json | 8 +- src/index.ts | 219 +++++++++++++++++++-------------------- src/integ.default.ts | 189 +++++++++++++++++++-------------- 8 files changed, 357 insertions(+), 306 deletions(-) diff --git a/.projen/deps.json b/.projen/deps.json index bc8a361..65b49e5 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -91,7 +91,7 @@ }, { "name": "aws-cdk-lib", - "version": "^2.166.0", + "version": "^2.173.2", "type": "peer" }, { diff --git a/.projenrc.ts b/.projenrc.ts index 352226f..67596e6 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -5,7 +5,7 @@ const project = new awscdk.AwsCdkConstructLibrary({ author: 'AlexTech314', authorAddress: 'alest314@gmail.com', majorVersion: 1, - cdkVersion: '2.166.0', + cdkVersion: '2.173.2', defaultReleaseBranch: 'main', packageManager: NodePackageManager.NPM, jsiiVersion: '~5.5.0', @@ -56,7 +56,9 @@ project.npmignore!.include('isComplete/*.js', 'onEvent/*.js'); project.addScripts({ 'local-deploy': 'cdk deploy --app "npx ts-node src/integ.default.ts"', + 'local-deploy-no-rollback': 'cdk deploy --no-rollback --app "npx ts-node src/integ.default.ts"', 'local-destroy': 'cdk destroy --app "npx ts-node src/integ.default.ts"', + 'local-synth': 'cdk synth --app "npx ts-node src/integ.default.ts"', }); project.synth(); diff --git a/API.md b/API.md index b69a2d7..0df823c 100644 --- a/API.md +++ b/API.md @@ -4,7 +4,7 @@ ### TokenInjectableDockerBuilder -A CDK construct to build and push Docker images to an ECR repository using CodeBuild and Lambda custom resources, retrieving the final image digest (SHA) and using that exact digest for ECS or Lambda references. +A CDK construct to build and push Docker images to an ECR repository using CodeBuild and Lambda custom resources, **then** retrieve the final image tag so that ECS/Lambda references use the exact digest. #### Initializers @@ -16,9 +16,9 @@ new TokenInjectableDockerBuilder(scope: Construct, id: string, props: TokenInjec | **Name** | **Type** | **Description** | | --- | --- | --- | -| scope | constructs.Construct | *No description.* | -| id | string | *No description.* | -| props | TokenInjectableDockerBuilderProps | *No description.* | +| scope | constructs.Construct | The scope in which to define this construct. | +| id | string | The scoped construct ID. | +| props | TokenInjectableDockerBuilderProps | Configuration for building and pushing the Docker image. | --- @@ -26,18 +26,24 @@ new TokenInjectableDockerBuilder(scope: Construct, id: string, props: TokenInjec - *Type:* constructs.Construct +The scope in which to define this construct. + --- ##### `id`Required - *Type:* string +The scoped construct ID. + --- ##### `props`Required - *Type:* TokenInjectableDockerBuilderProps +Configuration for building and pushing the Docker image. + --- #### Methods @@ -87,8 +93,8 @@ Any object. | **Name** | **Type** | **Description** | | --- | --- | --- | | node | constructs.Node | The tree node. | -| containerImage | aws-cdk-lib.aws_ecs.ContainerImage | An ECS-compatible ContainerImage referencing the *exact* SHA digest of the built Docker image. | -| dockerImageCode | aws-cdk-lib.aws_lambda.DockerImageCode | A Lambda-compatible DockerImageCode referencing the *exact* SHA digest of the built Docker image. | +| containerImage | aws-cdk-lib.aws_ecs.ContainerImage | An ECS-compatible container image referencing the tag of the built Docker image. | +| dockerImageCode | aws-cdk-lib.aws_lambda.DockerImageCode | A Lambda-compatible DockerImageCode referencing the the tag of the built Docker image. | --- @@ -112,7 +118,7 @@ public readonly containerImage: ContainerImage; - *Type:* aws-cdk-lib.aws_ecs.ContainerImage -An ECS-compatible ContainerImage referencing the *exact* SHA digest of the built Docker image. +An ECS-compatible container image referencing the tag of the built Docker image. --- @@ -124,7 +130,7 @@ public readonly dockerImageCode: DockerImageCode; - *Type:* aws-cdk-lib.aws_lambda.DockerImageCode -A Lambda-compatible DockerImageCode referencing the *exact* SHA digest of the built Docker image. +A Lambda-compatible DockerImageCode referencing the the tag of the built Docker image. --- @@ -150,8 +156,8 @@ const tokenInjectableDockerBuilderProps: TokenInjectableDockerBuilderProps = { . | path | string | The path to the directory containing the Dockerfile or source code. | | buildArgs | {[ key: string ]: string} | Build arguments to pass to the Docker build process. | | dockerLoginSecretArn | string | The ARN of the AWS Secrets Manager secret containing Docker login credentials. | -| installCommands | string[] | Custom commands to run during the install phase. | -| preBuildCommands | string[] | Custom commands to run during the pre_build phase. | +| installCommands | string[] | Custom commands to run during the install phase of CodeBuild. | +| preBuildCommands | string[] | Custom commands to run during the pre_build phase of CodeBuild. | | securityGroups | aws-cdk-lib.aws_ec2.ISecurityGroup[] | The security groups to attach to the CodeBuild project. | | subnetSelection | aws-cdk-lib.aws_ec2.SubnetSelection | The subnet selection to specify which subnets to use within the VPC. | | vpc | aws-cdk-lib.aws_ec2.IVpc | The VPC in which the CodeBuild project will be deployed. | @@ -180,7 +186,7 @@ public readonly buildArgs: {[ key: string ]: string}; Build arguments to pass to the Docker build process. -These are transformed into `--build-arg` flags. +These are transformed into `--build-arg KEY=VALUE` flags. --- @@ -212,7 +218,8 @@ This secret should store a JSON object with the following structure: } ``` If not provided (or not needed), the construct will skip Docker Hub login. -NOTE: The secret must be in the same region as the stack. + +**Note**: The secret must be in the same region as the stack. --- @@ -232,20 +239,16 @@ public readonly installCommands: string[]; - *Type:* string[] - *Default:* No additional install commands. -Custom commands to run during the install phase. +Custom commands to run during the install phase of CodeBuild. -**Example Usage:** -```typescript -new TokenInjectableDockerBuilder(this, 'MyDockerBuilder', { - path: path.resolve(__dirname, '../app'), - installCommands: [ - 'echo "Updating package lists..."', - 'apt-get update -y', - 'echo "Installing required packages..."', - 'apt-get install -y curl dnsutils', - ], - // ... other properties ... -}); +**Example**: +```ts +installCommands: [ + 'echo "Updating package lists..."', + 'apt-get update -y', + 'echo "Installing required packages..."', + 'apt-get install -y curl dnsutils', +], ``` --- @@ -259,18 +262,14 @@ public readonly preBuildCommands: string[]; - *Type:* string[] - *Default:* No additional pre-build commands. -Custom commands to run during the pre_build phase. +Custom commands to run during the pre_build phase of CodeBuild. -**Example Usage:** -```typescript -new TokenInjectableDockerBuilder(this, 'MyDockerBuilder', { - path: path.resolve(__dirname, '../app'), - preBuildCommands: [ - 'echo "Fetching configuration from private API..."', - 'curl -o config.json https://api.example.com/config', - ], - // ... other properties ... -}); +**Example**: +```ts +preBuildCommands: [ + 'echo "Fetching configuration from private API..."', + 'curl -o config.json https://api.example.com/config', +], ``` --- @@ -286,7 +285,7 @@ public readonly securityGroups: ISecurityGroup[]; The security groups to attach to the CodeBuild project. -These should define the network access rules for the CodeBuild project. +These define the network access rules for the CodeBuild project. --- diff --git a/isComplete/isComplete.js b/isComplete/isComplete.js index 396fa67..d0ec0d2 100644 --- a/isComplete/isComplete.js +++ b/isComplete/isComplete.js @@ -1,97 +1,120 @@ -const { CodeBuildClient, ListBuildsForProjectCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild'); -const { CloudWatchLogsClient, GetLogEventsCommand } = require('@aws-sdk/client-cloudwatch-logs'); - -exports.handler = async (event, context) => { - console.log('isCompleteHandler Event:', JSON.stringify(event, null, 2)); - - // Initialize AWS SDK v3 clients - const codebuildClient = new CodeBuildClient({ region: process.env.AWS_REGION }); - const cloudwatchlogsClient = new CloudWatchLogsClient({ region: process.env.AWS_REGION }); +const { + CodeBuildClient, + ListBuildsForProjectCommand, + BatchGetBuildsCommand, +} = require('@aws-sdk/client-codebuild'); +const { + CloudWatchLogsClient, + GetLogEventsCommand, +} = require('@aws-sdk/client-cloudwatch-logs'); + +exports.handler = async (event) => { + console.log('--- isComplete Handler Invoked ---'); + console.log('AWS_REGION:', process.env.AWS_REGION); + console.log('Event:', JSON.stringify(event, null, 2)); + + const region = process.env.AWS_REGION; + const codebuildClient = new CodeBuildClient({ region }); + const logsClient = new CloudWatchLogsClient({ region }); try { - const projectName = event.ResourceProperties.ProjectName; + const projectName = event.ResourceProperties?.ProjectName; + console.log('ProjectName from ResourceProperties:', projectName); if (!projectName) { - throw new Error('ProjectName is required in ResourceProperties'); + throw new Error('Missing ProjectName in ResourceProperties'); } - console.log(`Checking status for CodeBuild project: ${projectName}`); - - // Retrieve the latest build for the given project - const listBuildsCommand = new ListBuildsForProjectCommand({ - projectName: projectName, - sortOrder: 'DESCENDING', - maxResults: 1, - }); - - const listBuildsResp = await codebuildClient.send(listBuildsCommand); - const buildIds = listBuildsResp.ids; + // Handle Delete requests gracefully + if (event.RequestType === 'Delete') { + console.log('Delete request detected. Marking resource as complete.'); + return { IsComplete: true }; + } - if (!buildIds || buildIds.length === 0) { + // 1) Retrieve the latest build ID for this project + console.log('Querying CodeBuild for the most recent build...'); + const listResp = await codebuildClient.send( + new ListBuildsForProjectCommand({ + projectName, + sortOrder: 'DESCENDING', + maxResults: 1, + }) + ); + console.log('ListBuildsForProjectCommand response:', JSON.stringify(listResp, null, 2)); + + if (!listResp.ids || listResp.ids.length === 0) { throw new Error(`No builds found for project: ${projectName}`); } - const buildId = buildIds[0]; - console.log(`Latest Build ID: ${buildId}`); + const buildId = listResp.ids[0]; + console.log(`Identified latest Build ID: ${buildId}`); - // Get build details - const batchGetBuildsCommand = new BatchGetBuildsCommand({ - ids: [buildId], - }); - - const buildDetailsResp = await codebuildClient.send(batchGetBuildsCommand); - const build = buildDetailsResp.builds[0]; + // 2) Get details about that specific build + const batchResp = await codebuildClient.send( + new BatchGetBuildsCommand({ ids: [buildId] }) + ); + console.log('BatchGetBuildsCommand response:', JSON.stringify(batchResp, null, 2)); + const build = batchResp.builds?.[0]; if (!build) { throw new Error(`Build details not found for Build ID: ${buildId}`); } const buildStatus = build.buildStatus; - console.log(`Build Status: ${buildStatus}`); + console.log(`The build status for ID ${buildId} is: ${buildStatus}`); + // Check for in-progress status if (buildStatus === 'IN_PROGRESS') { - // Build is still in progress - console.log('Build is still in progress.'); + console.log('Build is still in progress. Requesting more time...'); return { IsComplete: false }; - } else if (buildStatus === 'SUCCEEDED') { - // Build succeeded - console.log('Build succeeded.'); - return { IsComplete: true }; - } else if (['FAILED', 'FAULT', 'STOPPED', 'TIMED_OUT'].includes(buildStatus)) { - // Build failed; retrieve last 5 log lines - const logsInfo = build.logs; - if (logsInfo && logsInfo.groupName && logsInfo.streamName) { - console.log(`Retrieving logs from CloudWatch Logs Group: ${logsInfo.groupName}, Stream: ${logsInfo.streamName}`); - - const getLogEventsCommand = new GetLogEventsCommand({ - logGroupName: logsInfo.groupName, - logStreamName: logsInfo.streamName, - startFromHead: false, // Start from the end to get latest logs - limit: 5, - }); - - const logEventsResp = await cloudwatchlogsClient.send(getLogEventsCommand); - const logEvents = logEventsResp.events; - const lastFiveMessages = logEvents.map((event) => event.message).reverse().join('\n'); + } - const errorMessage = `Build failed with status: ${buildStatus}\nLast 5 build logs:\n${lastFiveMessages}`; - console.error(errorMessage); + // If build succeeded, retrieve the final artifact with the digest + if (buildStatus === 'SUCCEEDED') { + return { + IsComplete: true, + Data: { + ImageTag: process.env.IMAGE_TAG, + }, + }; + } - // Throw an error to indicate failure to the CDK provider - throw new Error(errorMessage); + // If the build is in a failed status, retrieve CloudWatch logs + if (['FAILED', 'FAULT', 'STOPPED', 'TIMED_OUT'].includes(buildStatus)) { + console.log(`Build ended with status: ${buildStatus}. Attempting to retrieve last log lines...`); + const logsInfo = build.logs; + console.log('Logs info:', JSON.stringify(logsInfo, null, 2)); + + if (logsInfo?.groupName && logsInfo?.streamName) { + console.log(`Retrieving up to 5 log events from CloudWatch Logs in group ${logsInfo.groupName} stream ${logsInfo.streamName}`); + const logResp = await logsClient.send( + new GetLogEventsCommand({ + logGroupName: logsInfo.groupName, + logStreamName: logsInfo.streamName, + startFromHead: false, + limit: 5, + }) + ); + console.log('GetLogEventsCommand response:', JSON.stringify(logResp, null, 2)); + + const logEvents = logResp.events || []; + const lastFive = logEvents.map(e => e.message).reverse().join('\n'); + console.error('Last 5 build log lines:\n', lastFive); + + throw new Error(`Build failed with status ${buildStatus}. Last logs:\n${lastFive}`); } else { - const errorMessage = `Build failed with status: ${buildStatus}, but logs are not available.`; - console.error(errorMessage); - throw new Error(errorMessage); + throw new Error(`Build failed with status: ${buildStatus}, but no logs found.`); } - } else { - const errorMessage = `Unknown build status: ${buildStatus}`; - console.error(errorMessage); - throw new Error(errorMessage); } + + // If we reach here, it's an unexpected status + console.log(`Encountered unknown build status: ${buildStatus}`); + throw new Error(`Unknown build status: ${buildStatus}`); + } catch (error) { - console.error('Error in isCompleteHandler:', error); - // Rethrow the error to inform the CDK provider of the failure + console.error('--- Caught an error in isComplete handler ---'); + console.error('Error details:', error); + // re-throw for CloudFormation to see the error throw error; } }; diff --git a/package-lock.json b/package-lock.json index 7f39d21..3b31fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", - "aws-cdk-lib": "2.166.0", + "aws-cdk-lib": "2.173.2", "commit-and-tag-version": "^12", "constructs": "10.0.5", "eslint": "^9", @@ -33,7 +33,7 @@ "typescript": "^5.7.2" }, "peerDependencies": { - "aws-cdk-lib": "^2.166.0", + "aws-cdk-lib": "^2.173.2", "constructs": "^10.0.5" } }, @@ -1866,7 +1866,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.166.0", + "version": "2.173.2", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.173.2.tgz", + "integrity": "sha512-cL9+z8Pl3VZGoO7BwdsrFAOeud/vSl3at7OvmhihbNprMN15XuFUx/rViAU5OI1m92NbV4NBzYSLbSeCwYLNyw==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -1881,7 +1883,6 @@ "mime-types" ], "dev": true, - "license": "Apache-2.0", "dependencies": { "@aws-cdk/asset-awscli-v1": "^2.2.208", "@aws-cdk/asset-kubectl-v20": "^2.1.3", diff --git a/package.json b/package.json index 237a296..d75b8a6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "watch": "npx projen watch", "projen": "npx projen", "local-deploy": "cdk deploy --app \"npx ts-node src/integ.default.ts\"", - "local-destroy": "cdk destroy --app \"npx ts-node src/integ.default.ts\"" + "local-deploy-no-rollback": "cdk deploy --no-rollback --app \"npx ts-node src/integ.default.ts\"", + "local-destroy": "cdk destroy --app \"npx ts-node src/integ.default.ts\"", + "local-synth": "cdk synth --app \"npx ts-node src/integ.default.ts\"" }, "author": { "name": "AlexTech314", @@ -43,7 +45,7 @@ "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", - "aws-cdk-lib": "2.166.0", + "aws-cdk-lib": "2.173.2", "commit-and-tag-version": "^12", "constructs": "10.0.5", "eslint": "^9", @@ -62,7 +64,7 @@ "typescript": "^5.7.2" }, "peerDependencies": { - "aws-cdk-lib": "^2.166.0", + "aws-cdk-lib": "^2.173.2", "constructs": "^10.0.5" }, "keywords": [ diff --git a/src/index.ts b/src/index.ts index 99d62ab..bee473e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import * as crypto from 'crypto'; import * as path from 'path'; -import { CustomResource, Duration } from 'aws-cdk-lib'; +import { CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib'; import { Project, Source, LinuxBuildImage, BuildSpec } from 'aws-cdk-lib/aws-codebuild'; import { IVpc, ISecurityGroup, SubnetSelection } from 'aws-cdk-lib/aws-ec2'; import { Repository, RepositoryEncryption, TagStatus } from 'aws-cdk-lib/aws-ecr'; @@ -8,6 +8,7 @@ import { ContainerImage } from 'aws-cdk-lib/aws-ecs'; import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { Key } from 'aws-cdk-lib/aws-kms'; import { Runtime, Code, DockerImageCode, Function } from 'aws-cdk-lib/aws-lambda'; +import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3'; import { Asset } from 'aws-cdk-lib/aws-s3-assets'; import { Provider } from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; @@ -23,7 +24,7 @@ export interface TokenInjectableDockerBuilderProps { /** * Build arguments to pass to the Docker build process. - * These are transformed into `--build-arg` flags. + * These are transformed into `--build-arg KEY=VALUE` flags. * @example * { * TOKEN: 'my-secret-token', @@ -42,7 +43,8 @@ export interface TokenInjectableDockerBuilderProps { * } * ``` * If not provided (or not needed), the construct will skip Docker Hub login. - * NOTE: The secret must be in the same region as the stack. + * + * **Note**: The secret must be in the same region as the stack. * * @example 'arn:aws:secretsmanager:us-east-1:123456789012:secret:DockerLoginSecret' */ @@ -51,57 +53,52 @@ export interface TokenInjectableDockerBuilderProps { /** * The VPC in which the CodeBuild project will be deployed. * If provided, the CodeBuild project will be launched within the specified VPC. - * @default No VPC is attached, and the CodeBuild project will use public internet. + * + * @default - No VPC is attached, and the CodeBuild project will use public internet. */ readonly vpc?: IVpc; /** * The security groups to attach to the CodeBuild project. - * These should define the network access rules for the CodeBuild project. - * @default No security groups are attached. + * These define the network access rules for the CodeBuild project. + * + * @default - No security groups are attached. */ readonly securityGroups?: ISecurityGroup[]; /** * The subnet selection to specify which subnets to use within the VPC. * Allows the user to select private, public, or isolated subnets. - * @default All subnets in the VPC are used. + * + * @default - All subnets in the VPC are used. */ readonly subnetSelection?: SubnetSelection; /** - * Custom commands to run during the install phase. + * Custom commands to run during the install phase of CodeBuild. * - * **Example Usage:** - * ```typescript - * new TokenInjectableDockerBuilder(this, 'MyDockerBuilder', { - * path: path.resolve(__dirname, '../app'), - * installCommands: [ - * 'echo "Updating package lists..."', - * 'apt-get update -y', - * 'echo "Installing required packages..."', - * 'apt-get install -y curl dnsutils', - * ], - * // ... other properties ... - * }); + * **Example**: + * ```ts + * installCommands: [ + * 'echo "Updating package lists..."', + * 'apt-get update -y', + * 'echo "Installing required packages..."', + * 'apt-get install -y curl dnsutils', + * ], * ``` * @default - No additional install commands. */ readonly installCommands?: string[]; /** - * Custom commands to run during the pre_build phase. + * Custom commands to run during the pre_build phase of CodeBuild. * - * **Example Usage:** - * ```typescript - * new TokenInjectableDockerBuilder(this, 'MyDockerBuilder', { - * path: path.resolve(__dirname, '../app'), - * preBuildCommands: [ - * 'echo "Fetching configuration from private API..."', - * 'curl -o config.json https://api.example.com/config', - * ], - * // ... other properties ... - * }); + * **Example**: + * ```ts + * preBuildCommands: [ + * 'echo "Fetching configuration from private API..."', + * 'curl -o config.json https://api.example.com/config', + * ], * ``` * @default - No additional pre-build commands. */ @@ -109,22 +106,35 @@ export interface TokenInjectableDockerBuilderProps { } /** - * A CDK construct to build and push Docker images to an ECR repository using CodeBuild and Lambda custom resources, - * retrieving the final image digest (SHA) and using that exact digest for ECS or Lambda references. + * A CDK construct to build and push Docker images to an ECR repository using + * CodeBuild and Lambda custom resources, **then** retrieve the final image tag + * so that ECS/Lambda references use the exact digest. */ export class TokenInjectableDockerBuilder extends Construct { + /** + * The ECR repository that stores the resulting Docker image. + */ private readonly ecrRepository: Repository; /** - * An ECS-compatible ContainerImage referencing the *exact* SHA digest of the built Docker image. + * An ECS-compatible container image referencing the tag + * of the built Docker image. */ public readonly containerImage: ContainerImage; /** - * A Lambda-compatible DockerImageCode referencing the *exact* SHA digest of the built Docker image. + * A Lambda-compatible DockerImageCode referencing the the tag + * of the built Docker image. */ public readonly dockerImageCode: DockerImageCode; + /** + * Creates a new `TokenInjectableDockerBuilder`. + * + * @param scope The scope in which to define this construct. + * @param id The scoped construct ID. + * @param props Configuration for building and pushing the Docker image. + */ constructor(scope: Construct, id: string, props: TokenInjectableDockerBuilderProps) { super(scope, id); @@ -139,15 +149,15 @@ export class TokenInjectableDockerBuilder extends Construct { preBuildCommands, } = props; - // Generate a unique tag for this build. + // Generate an ephemeral tag for CodeBuild const imageTag = crypto.randomUUID(); - // KMS key for ECR encryption + // Define a KMS key for ECR encryption const encryptionKey = new Key(this, 'EcrEncryptionKey', { enableKeyRotation: true, }); - // ECR repository + // Create an ECR repository with encryption, lifecycle rules, and image scanning this.ecrRepository = new Repository(this, 'ECRRepository', { lifecycleRules: [ { @@ -162,74 +172,73 @@ export class TokenInjectableDockerBuilder extends Construct { imageScanOnPush: true, }); - // Package source code + // Wrap the source folder as an S3 asset for CodeBuild to use const sourceAsset = new Asset(this, 'SourceAsset', { path: sourcePath, }); - // Build args + // Create an S3 bucket to store the CodeBuild artifacts + const artifactBucket = new Bucket(this, 'ArtifactBucket', { + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + }); + + // Convert buildArgs to a CLI-friendly string const buildArgsString = buildArgs ? Object.entries(buildArgs) .map(([k, v]) => `--build-arg ${k}=${v}`) .join(' ') : ''; - // Docker Hub login commands + // Optional DockerHub login, if a secret ARN is provided const dockerLoginCommands = dockerLoginSecretArn ? [ - 'echo "Retrieving Docker credentials from Secrets Manager..."', + 'echo "Retrieving Docker credentials..."', 'apt-get update -y && apt-get install -y jq', `DOCKER_USERNAME=$(aws secretsmanager get-secret-value --secret-id ${dockerLoginSecretArn} --query SecretString --output text | jq -r .username)`, `DOCKER_PASSWORD=$(aws secretsmanager get-secret-value --secret-id ${dockerLoginSecretArn} --query SecretString --output text | jq -r .password)`, 'echo "Logging in to Docker Hub..."', - // Use non-stdin login to avoid TTY error: 'echo $DOCKER_PASSWORD | docker login --username $DOCKER_USERNAME --password-stdin', ] - : ['echo "No Docker credentials provided. Skipping Docker Hub login."']; + : ['echo "No Docker credentials. Skipping Docker Hub login."']; - // BuildSpec const buildSpecObj = { version: '0.2', phases: { install: { commands: [ 'echo "Beginning install phase..."', - ...(installCommands || []), + ...(installCommands ?? []), ], }, pre_build: { commands: [ - ...(preBuildCommands || []), + ...(preBuildCommands ?? []), ...dockerLoginCommands, 'echo "Retrieving AWS Account ID..."', 'export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)', - 'echo "Logging in to Amazon ECR..."', + 'echo "Logging into Amazon ECR..."', 'aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com', ], }, build: { commands: [ - 'echo "Build phase: Building the Docker image..."', + `echo "Building Docker image with tag ${imageTag}..."`, `docker build ${buildArgsString} -t $ECR_REPO_URI:${imageTag} $CODEBUILD_SRC_DIR`, ], }, post_build: { commands: [ - `echo "Post-build phase: Pushing the Docker image with tag ${imageTag}..."`, + `echo "Pushing Docker image with tag ${imageTag}..."`, `docker push $ECR_REPO_URI:${imageTag}`, - `export IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $ECR_REPO_URI:${imageTag})`, - 'echo "Image digest: $IMAGE_DIGEST"', - 'echo "{ \\"ImageDigest\\": \\"$IMAGE_DIGEST\\" }" > imageDetail.json', ], }, }, - artifacts: { - files: ['imageDetail.json'], - name: 'imageDetail', - }, }; - // CodeBuild project + + // Create the CodeBuild project const codeBuildProject = new Project(this, 'CodeBuildProject', { source: Source.s3({ bucket: sourceAsset.bucket, @@ -242,87 +251,77 @@ export class TokenInjectableDockerBuilder extends Construct { environmentVariables: { ECR_REPO_URI: { value: this.ecrRepository.repositoryUri }, }, + buildSpec: BuildSpec.fromObject(buildSpecObj), vpc, securityGroups, subnetSelection, - buildSpec: BuildSpec.fromObject(buildSpecObj), }); - // Permissions + // Grant CodeBuild the ability to interact with ECR this.ecrRepository.grantPullPush(codeBuildProject); - codeBuildProject.role?.addToPrincipalPolicy( - new PolicyStatement({ - actions: [ - 'ecr:GetAuthorizationToken', - 'ecr:GetDownloadUrlForLayer', - 'ecr:BatchCheckLayerAvailability', - ], - resources: [this.ecrRepository.repositoryArn], - }), - ); - + codeBuildProject.role?.addToPrincipalPolicy(new PolicyStatement({ + actions: [ + 'ecr:GetAuthorizationToken', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchCheckLayerAvailability', + ], + resources: [this.ecrRepository.repositoryArn], + })); if (dockerLoginSecretArn) { - codeBuildProject.role?.addToPrincipalPolicy( - new PolicyStatement({ - actions: ['secretsmanager:GetSecretValue'], - resources: [dockerLoginSecretArn], - }), - ); + codeBuildProject.role?.addToPrincipalPolicy(new PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [dockerLoginSecretArn], + })); } - encryptionKey.grantEncryptDecrypt(codeBuildProject.role!); - // onEvent handler + // Define the Lambda functions for custom resource event and completion handling const onEventHandlerFunction = new Function(this, 'OnEventHandlerFunction', { runtime: Runtime.NODEJS_18_X, code: Code.fromAsset(path.resolve(__dirname, '../onEvent')), handler: 'onEvent.handler', timeout: Duration.minutes(15), }); + onEventHandlerFunction.addToRolePolicy(new PolicyStatement({ + actions: ['codebuild:StartBuild'], + resources: [codeBuildProject.projectArn], + })); - onEventHandlerFunction.addToRolePolicy( - new PolicyStatement({ - actions: ['codebuild:StartBuild'], - resources: [codeBuildProject.projectArn], - }), - ); - - // isComplete handler const isCompleteHandlerFunction = new Function(this, 'IsCompleteHandlerFunction', { runtime: Runtime.NODEJS_18_X, code: Code.fromAsset(path.resolve(__dirname, '../isComplete')), + environment: { + IMAGE_TAG: imageTag, + }, handler: 'isComplete.handler', timeout: Duration.minutes(15), }); - isCompleteHandlerFunction.addToRolePolicy( - new PolicyStatement({ - actions: [ - 'codebuild:BatchGetBuilds', - 'codebuild:ListBuildsForProject', - 'logs:GetLogEvents', - 'logs:DescribeLogStreams', - 'logs:DescribeLogGroups', - 's3:GetObject', - 's3:GetBucketLocation', - ], - resources: ['*'], - }), - ); + isCompleteHandlerFunction.addToRolePolicy(new PolicyStatement({ + actions: [ + 'codebuild:BatchGetBuilds', + 'codebuild:ListBuildsForProject', + 'logs:GetLogEvents', + 'logs:DescribeLogStreams', + 'logs:DescribeLogGroups', + ], + resources: ['*'], + })); + artifactBucket.grantReadWrite(isCompleteHandlerFunction); encryptionKey.grantEncryptDecrypt(onEventHandlerFunction); encryptionKey.grantEncryptDecrypt(isCompleteHandlerFunction); this.ecrRepository.grantPullPush(onEventHandlerFunction); this.ecrRepository.grantPullPush(isCompleteHandlerFunction); - // Provider + // Create a custom resource provider that uses the above Lambdas const provider = new Provider(this, 'CustomResourceProvider', { onEventHandler: onEventHandlerFunction, isCompleteHandler: isCompleteHandlerFunction, queryInterval: Duration.seconds(30), }); - // Custom resource + // Custom Resource that triggers the CodeBuild and waits for completion const buildTriggerResource = new CustomResource(this, 'BuildTriggerResource', { serviceToken: provider.serviceToken, properties: { @@ -331,18 +330,14 @@ export class TokenInjectableDockerBuilder extends Construct { Trigger: crypto.randomUUID(), }, }); - buildTriggerResource.node.addDependency(codeBuildProject); - // Grab the SHA from the custom resource's response (set in isComplete handler) - const imageDigest = buildTriggerResource.getAttString('ImageDigest'); - - // ECS-compatible from ECR by digest - this.containerImage = ContainerImage.fromEcrRepository(this.ecrRepository, imageDigest); - - // Lambda-compatible from ECR by digest + // Retrieve the final Docker image tag from Data.ImageTag + // This creates a dependency on the Custom Resource... + const imageTagRef = buildTriggerResource.getAttString('ImageTag'); + this.containerImage = ContainerImage.fromEcrRepository(this.ecrRepository, imageTagRef); this.dockerImageCode = DockerImageCode.fromEcr(this.ecrRepository, { - tagOrDigest: imageDigest, + tagOrDigest: imageTagRef, }); } } diff --git a/src/integ.default.ts b/src/integ.default.ts index 6aec159..b986ab4 100644 --- a/src/integ.default.ts +++ b/src/integ.default.ts @@ -1,16 +1,15 @@ import * as path from 'path'; import * as cdk from 'aws-cdk-lib'; -import { RestApi, EndpointType, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway'; -import { Vpc, SubnetType, SecurityGroup, Peer, Port, InterfaceVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2'; -import { PolicyDocument, PolicyStatement, Effect, AnyPrincipal } from 'aws-cdk-lib/aws-iam'; -import { Runtime, Code, Function } from 'aws-cdk-lib/aws-lambda'; +import { DockerImageFunction } from 'aws-cdk-lib/aws-lambda'; import { TokenInjectableDockerBuilder } from './index'; const app = new cdk.App(); const stack = new cdk.Stack(app, 'IntegTestingStack'); -// The TokenInjectableDockerBuilder construct can be used to build Docker images in public internet and docker login scenario. -new TokenInjectableDockerBuilder(stack, 'PublicBuilder', { +// ------------------------------------------------------------------------------------- +// 1) PublicBuilder with Docker Hub Login +// ------------------------------------------------------------------------------------- +const publicBuilder = new TokenInjectableDockerBuilder(stack, 'PublicBuilder', { path: path.resolve(__dirname, '../test-docker/public-internet'), buildArgs: { SAMPLE_ARG_1: 'SAMPLE_VALUE_1', @@ -23,8 +22,21 @@ new TokenInjectableDockerBuilder(stack, 'PublicBuilder', { dockerLoginSecretArn: 'arn:aws:secretsmanager:us-west-1:281318412783:secret:DockerLogin-k04Usw', }); -// The TokenInjectableDockerBuilder construct can be used to build Docker images in public internet without docker login scenario. -new TokenInjectableDockerBuilder(stack, 'PublicBuilderNoDockerLogin', { +// Create a test Lambda that uses the publicBuilder's Docker image +const publicBuilderTestLambda = new DockerImageFunction(stack, 'PublicBuilderTestLambda', { + code: publicBuilder.dockerImageCode, + // Minimal handler example. The Docker container can have any logic you want. + environment: { + TEST_ENV_VAR: 'HelloFromPublicBuilder', + }, +}); + +publicBuilderTestLambda.node.addDependency(publicBuilder); + +// ------------------------------------------------------------------------------------- +// 2) PublicBuilderNoDockerLogin +// ------------------------------------------------------------------------------------- +const publicBuilderNoLogin = new TokenInjectableDockerBuilder(stack, 'PublicBuilderNoDockerLogin', { path: path.resolve(__dirname, '../test-docker/public-internet'), buildArgs: { SAMPLE_ARG_1: 'SAMPLE_VALUE_1', @@ -36,85 +48,81 @@ new TokenInjectableDockerBuilder(stack, 'PublicBuilderNoDockerLogin', { }, }); -// The TokenInjectableDockerBuilder construct can be used to build Docker images in private subnet without docker login scenario. -// Create a VPC with private and public subnets -const vpc = new Vpc(stack, 'TestVPC', { - maxAzs: 2, - subnetConfiguration: [ - { - subnetType: SubnetType.PUBLIC, - name: 'PublicSubnet', - }, - { - subnetType: SubnetType.PRIVATE_WITH_EGRESS, - name: 'PrivateSubnet', - }, - ], +const publicNoLoginTestLambda = new DockerImageFunction(stack, 'PublicNoLoginTestLambda', { + code: publicBuilderNoLogin.dockerImageCode, + environment: { + TEST_ENV_VAR: 'HelloFromNoLoginBuilder', + }, }); -// Create a security group for the private API Gateway and CodeBuild -const apiSecurityGroup = new SecurityGroup(stack, 'ApiSecurityGroup', { - vpc, - allowAllOutbound: true, -}); +publicNoLoginTestLambda.node.addDependency(publicBuilderNoLogin); -// Allow inbound HTTPS traffic from the VPC (for the VPC endpoint) -apiSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443), 'Allow HTTPS traffic'); +// // ------------------------------------------------------------------------------------- +// // 3) Create a simple VPC + Private API to test the "private" scenario +// // ------------------------------------------------------------------------------------- +// const vpc = new Vpc(stack, 'TestVPC', { +// maxAzs: 2, +// subnetConfiguration: [ +// { subnetType: SubnetType.PUBLIC, name: 'PublicSubnet' }, +// { subnetType: SubnetType.PRIVATE_WITH_EGRESS, name: 'PrivateSubnet' }, +// ], +// }); -// Create a VPC endpoint for API Gateway -const apiGatewayEndpoint = vpc.addInterfaceEndpoint('ApiGatewayVpcEndpoint', { - service: InterfaceVpcEndpointAwsService.APIGATEWAY, - subnets: { - subnetType: SubnetType.PRIVATE_WITH_EGRESS, // Use private subnets - }, - securityGroups: [apiSecurityGroup], // Attach the security group - privateDnsEnabled: true, // Enable private DNS -}); +// const apiSecurityGroup = new SecurityGroup(stack, 'ApiSecurityGroup', { +// vpc, +// allowAllOutbound: true, +// }); +// apiSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443), 'Allow HTTPS traffic'); -// Lambda function to provide test configurations -const testConfigLambda = new Function(stack, 'TestConfigLambda', { - runtime: Runtime.NODEJS_18_X, - handler: 'index.handler', - code: Code.fromInline(` - exports.handler = async (event) => { - return { - statusCode: 200, - body: JSON.stringify({ - SAMPLE_CONFIG: "This is a test configuration", - }), - }; - }; - `), - vpc, - securityGroups: [apiSecurityGroup], -}); +// // Private Endpoint for API Gateway +// const apiGatewayEndpoint = vpc.addInterfaceEndpoint('ApiGatewayVpcEndpoint', { +// service: InterfaceVpcEndpointAwsService.APIGATEWAY, +// subnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS }, +// securityGroups: [apiSecurityGroup], +// privateDnsEnabled: true, +// }); -// Update the API Gateway resource policy -const privateApi = new RestApi(stack, 'PrivateApi', { - endpointTypes: [EndpointType.PRIVATE], - defaultIntegration: new LambdaIntegration(testConfigLambda), - policy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [new AnyPrincipal()], - actions: ['execute-api:Invoke'], - resources: ['execute-api:/*'], - conditions: { - StringEquals: { - 'aws:SourceVpce': apiGatewayEndpoint.vpcEndpointId, - }, - }, - }), - ], - }), -}); +// // A simple Lambda that returns a JSON object +// const testConfigLambda = new Function(stack, 'TestConfigLambda', { +// runtime: Runtime.NODEJS_18_X, +// handler: 'index.handler', +// code: Code.fromInline(` +// exports.handler = async () => ({ +// statusCode: 200, +// body: JSON.stringify({ SAMPLE_CONFIG: "This is a test configuration" }), +// }); +// `), +// vpc, +// securityGroups: [apiSecurityGroup], +// }); + +// // A Private API with a resource "/test-config" -> testConfigLambda +// const privateApi = new RestApi(stack, 'PrivateApi', { +// endpointTypes: [EndpointType.PRIVATE], +// defaultIntegration: new LambdaIntegration(testConfigLambda), +// policy: new PolicyDocument({ +// statements: [ +// new PolicyStatement({ +// effect: Effect.ALLOW, +// principals: [new AnyPrincipal()], +// actions: ['execute-api:Invoke'], +// resources: ['execute-api:/*'], +// conditions: { +// StringEquals: { 'aws:SourceVpce': apiGatewayEndpoint.vpcEndpointId }, +// }, +// }), +// ], +// }), +// }); -// Add a resource and method to the private API Gateway -const testConfigResource = privateApi.root.addResource('test-config'); -testConfigResource.addMethod('GET'); +// Create a resource + GET method for test-config +// const testConfigResource = privateApi.root.addResource('test-config'); +// testConfigResource.addMethod('GET'); -// TokenInjectableDockerBuilder: Fetch configuration from the private API +// ------------------------------------------------------------------------------------- +// 4) (Optional) Use a private builder that fetches the /test-config data +// Uncomment to see a private-subnet CodeBuild scenario that curls from the private API +// ------------------------------------------------------------------------------------- // const privateBuilder = new TokenInjectableDockerBuilder(stack, 'PrivateBuilder', { // path: path.resolve(__dirname, '../test-docker/private-subnet'), // buildArgs: { @@ -136,6 +144,27 @@ testConfigResource.addMethod('GET'); // 'curl -o config.json $API_URL', // ], // }); - // privateBuilder.node.addDependency(privateApi); // privateBuilder.node.addDependency(testConfigResource); + +// A test Lambda referencing the PrivateBuilder's Docker image, if uncommented above +// const privateBuilderTestLambda = new DockerImageFunction(stack, 'PrivateBuilderTestLambda', { +// code: privateBuilder.dockerImageCode, +// environment: { +// TEST_ENV_VAR: 'HelloFromPrivateBuilder', +// }, +// }); + +// // Optionally add a resource to the PrivateApi for that private builder's Lambda +// // const privateBuilderResource = privateApi.root.addResource('private-builder-test'); +// // privateBuilderResource.addMethod('GET', new LambdaIntegration(privateBuilderTestLambda)); + +// // ------------------------------------------------------------------------------------- +// // 5) Expose Lambdas for "publicBuilder" & "publicBuilderNoLogin" as new resources +// // on the PrivateApi (for demonstration). In reality, you might use a public API or direct invocation. +// // ------------------------------------------------------------------------------------- +// const publicBuilderResource = privateApi.root.addResource('public-builder-test'); +// publicBuilderResource.addMethod('GET', new LambdaIntegration(publicBuilderTestLambda)); + +// const publicNoLoginResource = privateApi.root.addResource('public-builder-no-login-test'); +// publicNoLoginResource.addMethod('GET', new LambdaIntegration(publicNoLoginTestLambda));