typescriptamazon-web-servicesaws-lambdaaws-cdk

How deploy aws lambda function in one aws account using ecr image from another aws account through cdk typescript


How deploy aws lambda function in one aws account using ecr image from another aws account through cdk typescript?

Below is our cdk code in typescript in different files.

# bin/ApisecDataScienceInfra.ts file content.
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { AppDeployPipelineStack } from "../lib/pipeline/application_deployment_pipeline_stack";
import { PipelineStack } from "../lib/pipeline/pipeline_stack";
import { accountConfig } from "../aws_accounts";

const app = new cdk.App();
const ecrRepositoryName = "apisec_data_science_repo";

const infraPipelineStack = new PipelineStack(
  app,
  "ApisecDataScienceInfrastructurePipelineStack",
  {
    env: {
      account: accountConfig.stages.shared.account,
      region: accountConfig.stages.shared.region,
    },
    ecrRepositoryName: ecrRepositoryName,
  }
);

const applicationPipelineStack = new AppDeployPipelineStack(
  app,
  "ApisecDataSciencePipeline",
  {
    env: {
      account: accountConfig.stages.shared.account,
      region: accountConfig.stages.shared.region,
    },
    ecrRepositoryName: ecrRepositoryName,
    codeCommitRepositoryName: "ApisecDataScienceInfra",
  }
);


# aws_accounts.ts file content
export const accountConfig = {
  stages: {
    shared: {
      account: "share-xxxxx",
      region: "us-east-1",
    },
    dev: {
      account: "dev-xxxxxx",
      region: "us-east-1",
    },
    qa: {
      account: "qa-xxxxxxx",
      region: "us-east-1",
    },
    prod: {
      account: "prod-xxxxxxx",
      region: "us-east-1",
    },
  },
};


# lib/pipeline/pipeline_stack.ts file content

import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  CodePipeline,
  CodePipelineSource,
  ShellStep,
} from "aws-cdk-lib/pipelines";
import { PipelineStage } from "./pipeline_stage";
import { Role, ServicePrincipal, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
import { accountConfig } from "../../aws_accounts";
import { Repository } from "aws-cdk-lib/aws-ecr";

export interface PipelineStackProps extends StackProps {
  readonly ecrRepositoryName: string;
}
export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: PipelineStackProps) {
    super(scope, id, props);
    // Reference the GitHub token stored in Secrets Manager
    const githubToken = Secret.fromSecretNameV2(
      this,
      "GitHubToken",
      "github-token"
    );

    const pipelineDeploymentRole = new Role(this, "PipelineDeploymentRole", {
      assumedBy: new ServicePrincipal("codepipeline.amazonaws.com"),
    });

    pipelineDeploymentRole.addToPolicy(
      new PolicyStatement({
        actions: ["sts:AssumeRole"],
        resources: [
          `arn:aws:iam::${accountConfig.stages.dev.account}:role/cdk-hnb659fds-*`,
          `arn:aws:iam::${accountConfig.stages.qa.account}:role/cdk-hnb659fds-*`,
          `arn:aws:iam::${accountConfig.stages.prod.account}:role/cdk-hnb659fds-*`,
        ],
      })
    );
    const deploymentEcrRepository = Repository.fromRepositoryName(
      this,
      `{id}-ECRRepoName`,
      props?.ecrRepositoryName!
    );

    const codepipeline = new CodePipeline(
      this,
      "ApisecDataScienceInfraPipeline",
      {
        pipelineName: "ApisecDataScienceInfraPipeline",
        synth: new ShellStep("Synth", {
          input: CodePipelineSource.gitHub(
            "test/testDataScienceInfra",
            "build",
            {
              authentication: githubToken.secretValue,
            }
          ),
          commands: ["npm ci", "npm run build", "npx cdk synth"],
        }),
        crossAccountKeys: true,
        role: pipelineDeploymentRole,
      }
    );

    codepipeline.addStage(
      new PipelineStage(this, `${id}-devStage`, {
        stageName: "dev",
        env: {
          account: accountConfig.stages.dev.account,
          region: accountConfig.stages.dev.region,
        },
        ecrRepository: deploymentEcrRepository,
      })
    );

    codepipeline.addStage(
      new PipelineStage(this, `${id}-qaStage`, {
        stageName: "qa",
        env: {
          account: accountConfig.stages.qa.account,
          region: accountConfig.stages.qa.region,
        },
        ecrRepository: deploymentEcrRepository,
      })
    );
    codepipeline.addStage(
      new PipelineStage(this, `${id}-prodStage`, {
        stageName: "prod",
        env: {
          account: accountConfig.stages.prod.account,
          region: accountConfig.stages.prod.region,
        },
        ecrRepository: deploymentEcrRepository,
      })
    );
  }
}

# lib/pipeline/pipeline_stage.ts file content

import { Construct } from "constructs";
import { Stage, StageProps } from "aws-cdk-lib";
import { ApisecDataScienceInfrastructureStack } from "../stack/apisec_data_science_infra_stack";
import { IRepository } from "aws-cdk-lib/aws-ecr";

export interface PipelineStageProps extends StageProps {
  readonly ecrRepository: IRepository;
}

export class PipelineStage extends Stage {
  constructor(scope: Construct, id: string, props?: PipelineStageProps) {
    super(scope, id, props);

    const baseInfra = new ApisecDataScienceInfrastructureStack(
      this,
      `ApisecDataScienceInfrastructureStack-${props?.stageName}`,
      {
        env: {
          account: props?.env?.account!,
          region: props?.env?.region!,
        },
        ecrRepository: props?.ecrRepository!,
        serviceName: "DataScienceService",
        stage: props?.stageName!,
      }
    );
  }
}

# lib/stack/apisec_data_science_infra_stack.ts file content

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { ECSFargateService } from "../contruct/ecs_fargate_service";
import { DataScienceLambdaService } from "../contruct/data_science_lambda_service";
import { IRepository } from "aws-cdk-lib/aws-ecr";

export interface ApisecDataScienceInfrastructureProps
  extends cdk.StackProps {
  readonly ecrRepository: IRepository;
  readonly serviceName: string;
  readonly stage: string;
}

export class ApisecDataScienceInfrastructureStack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    props?: ApisecDataScienceInfrastructureProps
  ) {
    super(scope, id, props);

    if (!props?.ecrRepository) {
      throw new Error("ECR repository must be provided!");
    }
    const datasciencelambdaservice = new DataScienceLambdaService(this, "DataScienceLambdaServiceConstruct", {
      ecrRepository: props.ecrRepository,
      stage: props?.stage
    })
  }
}


# lib/contruct/data_science_lambda_service.ts file content

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { IRepository } from "aws-cdk-lib/aws-ecr";

export interface DataScienceLambdaServiceProps {
  readonly ecrRepository: IRepository;
  readonly stage: string;
}

export class DataScienceLambdaService extends Construct {
  constructor(scope: Construct, id: string, props: DataScienceLambdaServiceProps) {
    super(scope, id);

    const azs = ["us-east-2a", "us-east-2b", "us-east-2c"];

    // Import Public and Private Subnet IDs
    const vpcPublicSubnetIdsRef = cdk.Token.asList(
      cdk.Fn.split(",", cdk.Fn.importValue("PublicSubnetIds"))
    );
    const vpcPrivateSubnetIdsRef = cdk.Token.asList(
      cdk.Fn.split(",", cdk.Fn.importValue("PrivateSubnetIds"))
    );
    const privateSubnetIds: string[] = azs.map((_, index) => cdk.Fn.select(index, vpcPrivateSubnetIdsRef));

    // VPC
    const vpc = ec2.Vpc.fromVpcAttributes(this, `${id}-VpcImport`, {
      vpcId: cdk.Fn.importValue("VPCId"),
      availabilityZones: azs,
      privateSubnetIds: privateSubnetIds,
    });

    const lambdaBucket = new s3.Bucket(this, "LambdaS3Bucket", {
      bucketName: `apisec-datascience-lambda-${props?.stage}-assets`,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    // IAM Role for Lambda
    const lambdaRole = new iam.Role(this, "LambdaExecutionRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
    });

    lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"));

    // Additional permissions for CloudWatch Logs
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
        resources: ["arn:aws:logs:*:*:log-group:/aws/lambda/*:*"],
      })
    );

    lambdaBucket.grantReadWrite(lambdaRole);

    // Grant Lambda Role Permissions to Pull from ECR
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:DescribeImages",
          "ecr:BatchCheckLayerAvailability",
          "ecr:PassRole"
        ],
        resources: ["*"],
      })
    );

    // Allow Lambda to Authenticate with ECR
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["ecr:GetAuthorizationToken"],
        resources: ["*"],
      })
    );
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          "ec2:CreateNetworkInterface",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DeleteNetworkInterface"
        ],
        resources: ["*"],
      })
    );
    // Define Lambda Function Configurations
    const lambdaConfigs = [
      {
        functionName: "extract_endpoints_and_params_handler",
        memorySize: 4096,
        cmdOverride: ["src.business_flows.handlers.extract_endpoints_and_params_handler"],
        timeoutDuration: 15,
      },
      {
        functionName: "score_two_params_handler",
        memorySize: 512,
        cmdOverride: ["src.business_flows.handlers.score_two_params_handler"],
        timeoutDuration: 1,
      },
      {
        functionName: "get_business_flows_handler",
        memorySize: 512,
        cmdOverride: ["src.business_flows.handlers.get_business_flows_handler"],
        timeoutDuration: 1,
      },
    ];

    lambdaConfigs.forEach((config) => {
      // Create CloudWatch Log Group for each Lambda function
      const logGroup = new logs.LogGroup(this, `${config.functionName}LogGroup`, {
        logGroupName: `/aws/lambda/${config.functionName}`,
        retention: logs.RetentionDays.THREE_MONTHS,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      });

      // Create Lambda Function
      const lambdaFunction = new lambda.DockerImageFunction(this, config.functionName, {
        functionName: config.functionName,
        code: lambda.DockerImageCode.fromEcr(props.ecrRepository, {
          tag: "latest",
          cmd: config.cmdOverride,
        }),
        role: lambdaRole,
        memorySize: config.memorySize,
        environment: {
          S3_BUCKET_NAME: lambdaBucket.bucketName,
          LOG_GROUP_NAME: logGroup.logGroupName,
          OPENAI_API_KEY: "testapikey"
        },
        timeout: cdk.Duration.minutes(config.timeoutDuration),
        //vpc: vpc,
        //vpcSubnets: {
          //subnets: privateSubnetIds.map((id, index) => 
           // ec2.Subnet.fromSubnetId(this, `${config.functionName}-Subnet-${index}`, id)
         //),
        //},
      });

      // Grant Lambda function write access to CloudWatch Log Group
      logGroup.grantWrite(lambdaFunction);

      // Export Lambda Function Name
      new cdk.CfnOutput(this, `${config.functionName}Name`, {
        exportName: `${props?.stage}-${config.functionName.replace(/_/g,"-")}-Name`,
        value: lambdaFunction.functionName,
      });

      // Export Lambda Function ARN
      new cdk.CfnOutput(this, `${config.functionName}Arn`, {
        exportName: `${props?.stage}-${config.functionName.replace(/_/g, "-")}-Arn`,
        value: lambdaFunction.functionArn,
      });
    });
  }
}


# lib/pipeline/application_deployment_pipeline_stack.ts file contents

import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
import * as codepipeline_actions from "aws-cdk-lib/aws-codepipeline-actions";
import { Construct } from "constructs";
import { Repository } from "aws-cdk-lib/aws-codecommit";
import { accountConfig } from "../../aws_accounts";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";

export interface AppDeployPipelineStackProps extends cdk.StackProps {
  readonly ecrRepositoryName: string;
  readonly codeCommitRepositoryName: string;
}

export class AppDeployPipelineStack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    props?: AppDeployPipelineStackProps
  ) {
    super(scope, id, props);

    const stages = ["dev", "qa", "prod"];
    const functionNames = [
      "extract_endpoints_and_params_handler",
      "score_two_params_handler",
      "get_business_flows_handler",
    ];

    // ECR repository
    const ecrRepository = new ecr.Repository(this, `${id}ECRRepo`, {
      repositoryName: props?.ecrRepositoryName,
    });
    ecrRepository.addToResourcePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        principals: [
          new iam.AccountPrincipal(accountConfig.stages.dev.account),
          new iam.AccountPrincipal(accountConfig.stages.qa.account),
          new iam.AccountPrincipal(accountConfig.stages.prod.account),
        ],
        actions: ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability", "ecr:GetAuthorizationToken"],
      })
    );
    
    // IAM Policy for Lambda update permissions
    const lambdaUpdatePolicy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["lambda:UpdateFunctionCode", "lambda:GetFunction"],
      resources: ["arn:aws:lambda:*:*:function:*"]
    });
    
    // CodeBuild project
    const project = new codebuild.Project(this, `{id}CodeBuild`, {
      projectName: `${this.stackName}`,
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        privileged: true,
      },
      environmentVariables: {
        REPOSITORY_URI: {
          value: `${ecrRepository.repositoryUri}`,
        },
      },
      buildSpec: codebuild.BuildSpec.fromObject({
        version: "0.2",
        phases: {
          install: {
            "runtime-versions": {
              python: "3.11",
            },
          },
          pre_build: {
            commands: [
              "apt-get update",
              "apt-get install uuid-runtime",
              "echo Logging in to Amazon ECR",
              "aws --version",
              "ACCOUNT_ID=$(echo $CODEBUILD_BUILD_ARN | cut -f5 -d ':')",
              "aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com",
              "IMAGE_TAG=$(uuidgen)",
            ],
          },
          build: {
            commands: [
              "echo Build started on `date`",
              "echo Building Docker Image",
              "docker build -t apisecdatascienceservice:latest .",
              "docker tag apisecdatascienceservice:latest $REPOSITORY_URI:latest",
              "docker tag apisecdatascienceservice:latest $REPOSITORY_URI:$IMAGE_TAG",
            ],
          },
          post_build: {
            commands: [
              "echo Build completed on `date`",
              "echo Pushing the Docker images...",
              "docker push $REPOSITORY_URI:latest",
              "docker push $REPOSITORY_URI:$IMAGE_TAG",
              "echo Writing image definitions file...",
              "echo $IMAGE_TAG > imageTag.txt",
              "pwd; ls -al; cat imageTag.txt",
            ],
          },
        },
        artifacts: {
          files: ["imageTag.txt"],
        },
      }),
    });
    project.addToRolePolicy(lambdaUpdatePolicy);

    // ***pipeline actions***
    const sourceOutput = new codepipeline.Artifact();
    const buildOutput = new codepipeline.Artifact();

    const githubToken = Secret.fromSecretNameV2(
      this,
      "GitHubToken",
      "github-token"
    );
    const sourceAction = new codepipeline_actions.GitHubSourceAction({
      actionName: "GitHub_Source",
      owner: "test",
      repo: "test-datascience",
      branch: "main",
      oauthToken: githubToken.secretValue,
      output: sourceOutput,
    });

    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: "codebuild",
      project: project,
      input: sourceOutput,
      outputs: [buildOutput],
    });

    const pipeline = new codepipeline.Pipeline(this, id, {
      pipelineName: id,
      stages: [
        {
          stageName: "source",
          actions: [sourceAction],
        },
        {
          stageName: "build",
          actions: [buildAction],
        },
      ],
      crossAccountKeys: true,
    });

    // Add Lambda update deployment stage
    stages.forEach((stage) => {
      const DeployAction = new codepipeline_actions.CodeBuildAction({
        actionName: `DeployLambda-${stage}`,
        project: new codebuild.PipelineProject(this, `DeployLambdaProject-${stage}`, {
          buildSpec: codebuild.BuildSpec.fromObject({
            version: "0.2",
            phases: {
              build: {
                commands: [
                  "IMAGE_TAG=$(cat imageTag.txt)",
                  ...functionNames.map(
                    (functionName) =>
                      `aws lambda update-function-code --function-name ${functionName} --image-uri $REPOSITORY_URI:$IMAGE_TAG --region $AWS_DEFAULT_REGION`
                  ),
                ],
              },
            },
          }),
          environmentVariables: {
            REPOSITORY_URI: { value: `${ecrRepository.repositoryUri}` },
          },
        }),
        input: buildOutput,
      });

      pipeline.addStage({ stageName: `Deploy-${stage}`, actions: [DeployAction] });
    });

    ecrRepository.grantPullPush(project.role!);

    new cdk.CfnOutput(this, "image", {
      value: ecrRepository.repositoryUri + ":latest",
    });
  }
}

Given sample code. Now while creating lambda-function with ecr image in other aws account we are getting below error messages and lambda-function creation is failing with below error message

This AWS::Lambda::Function resource is in a CREATE_FAILED state.

Resource handler returned message: "Lambda does not have permission to access the ECR image. Check the ECR permissions. (Service: Lambda, Status Code: 403, Request ID: 703ba847-ccad-46ef-8092-4331db8a74ac) (SDK Attempt Count: 1)" (RequestToken: 0d10a127-84b9-4553-8c04-ec5cc5aa09dd, HandlerErrorCode: AccessDenied)

So based on the current cdk code what changes are required to fix this issue?


Solution

  • It's all described here.

    Basically you need to add these statements to your ECR resource policy:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "CrossAccountPermission",
          "Effect": "Allow",
          "Action": [
            "ecr:BatchGetImage",
            "ecr:GetDownloadUrlForLayer"
          ],
          "Principal": {
            "AWS": "arn:aws:iam::123456789012:root"
          }
        },
        {
          "Sid": "LambdaECRImageCrossAccountRetrievalPolicy",
          "Effect": "Allow",
          "Action": [
            "ecr:BatchGetImage",
            "ecr:GetDownloadUrlForLayer"
          ],
          "Principal": {
            "Service": "lambda.amazonaws.com"
          },
          "Condition": {
            "StringLike": {
              "aws:sourceARN": "arn:aws:lambda:us-east-1:123456789012:function:*"
            }
          }
        }
      ]
    }
    

    For your CDK code that probably looks something like:

    ecrRepository.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: "LambdaECRImageCrossAccountRetrievalPolicy",
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('lambda.amazonaws.com')],
        actions: ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"],
        conditions: {
          StringLike: {
            'aws:sourceArn': [
              `arn:aws:lambda:eu-west-2:${accountConfig.stages.dev.account}:function:*`,
              `arn:aws:lambda:eu-west-2:${accountConfig.stages.qa.account}:function:*`,
              `arn:aws:lambda:eu-west-2:${accountConfig.stages.prod.account}:function:*`
            ]
          },
        },
      })
    );
    

    And you should be able to remove "ecr:BatchCheckLayerAvailability" and "ecr:GetAuthorizationToken" from your cross account permissions statement - they're not needed for the Lambda service principal to pull an image.

    Also note that the role for your Lambda function does not need permissions to pull the image - that's all done by the Lambda service principal. So you should review those permissions and remove the ones not needed - in particular iam:PassRole on '*'.