diff --git a/api-gateway-serverless/README.md b/api-gateway-serverless/README.md new file mode 100644 index 00000000..f7eef449 --- /dev/null +++ b/api-gateway-serverless/README.md @@ -0,0 +1,71 @@ +# API Gateway and Serverless Inference Endpoint Deployment Pipeline + +## Purpose + +Use this template to automate the deployment of models in the Amazon SageMaker model registry to SageMaker Serverlss Endpoints with a API Gateway for real-time inference. The template provisions an AWS CodeCommit repository with configuration files to specify the model deployment steps, CloudFormation templates to define endpoints as infrastructure, and seed code for testing the endpoint. You can customize the template to suit your requirements or add more tests. AWS CodePipeline is used to orchestrate the model deployment. Model building pipeline: None Code repository: AWS CodeCommit Orchestration: AWS CodePipeline + +This project is derived from the built-in [MLOps template for model deployment](https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-projects-templates-sm.html#sagemaker-projects-templates-code-commit) but uses serverless inference endpoints. + +## Architecture + +![architecture](images/api-gateway-serverless-architecture.svg) + +## Instructions + + +Part 1: Create initial Service Catalog Product + +1. To create the Service Catalog product for this project, download the `create-api-gateway-serverless-product.yaml` and upload it into your CloudFormation console: https://console.aws.amazon.com/cloudformation/home?#/stacks/create/template + + +2. Update the Parameters section: + + - Supply a unique name for the stack + + ![](images/serverless-params-01.png) + + - Enter your Service Catalog portfolio id, which can be found in the __Outputs__ tab of your deployed portfolio stack or in the Service Catalog portfolio list: https://console.aws.amazon.com/servicecatalog/home?#/portfolios + + ![](images/serverless-params-02.png) + + - Update the Product Information. The product name and description are visible inside of SageMaker Studio. Other fields are visible to users that consume this directly through Service Catalog. + + - Support information is not available inside of SageMaker Studio, but is available in the Service Catalog Dashboard. + + - Updating the source code repository information is only necessary if you forked this repo and modified it. + +3. Choose __Next__, __Next__ again, check the box acknowledging that the template will create IAM resources, and then choose __Create Stack__. + +4. Your template should now be visible inside of SageMaker Studio. + + +Part 2: Deploy the Project inside of SageMaker Studio + +1. Open SageMaker Studio and sign in to your user profile. + +1. Choose the SageMaker __components and registries__ icon on the left, and choose the __Create project__ button. + +1. The default view displays SageMaker templates. Switch to the __Organization__ templates tab to see custom project templates. + +1. The template you created will be displayed in the template list. (If you do not see it yet, make sure the correct execution role is added to the product and the __sagemaker:studio-visibility__ tag with a value of __true__ is added to the Service Catalog product). + +1. Choose the template and click Select the correct project template. + + ![](../images/sm-projects-listing.png) + +6. Fill out the required fields for this project. + + - __Name:__ A unique name for the project deployment. + + - __Description:__ Project description for this deployment. + + - __UseRoleArn:__ The ARN of the IAM Role which is used by the CodePipeline. The template will create a default role for you which is based on the SageMaker-provided template, but has extra permisions to create and manage API Gateway resources. Change this only if you need to customize the IAM Role policy. + + - __SourceModelpackageGroupname__: The SageMaker model registry model packag group that you want to deploy with this pipeline template. + + +7. Choose __Create Project__. + + ![](images/serverless-create-project.png) + +8. After a few minutes, your example project should be deployed and ready to use. \ No newline at end of file diff --git a/api-gateway-serverless/create-api-gateway-serverless-product.yaml b/api-gateway-serverless/create-api-gateway-serverless-product.yaml new file mode 100644 index 00000000..90a1f8ef --- /dev/null +++ b/api-gateway-serverless/create-api-gateway-serverless-product.yaml @@ -0,0 +1,721 @@ +--- +AWSTemplateFormatVersion: "2010-09-09" + +Description: >- + Template for creating a Service Catalog product based on the api-gateway-serverless SageMaker Custom Example + +Parameters: + + PortfolioIDParameter: + Type: String + Description: The ID of the Service Catalog Portfolio you wish you create the product inside. If using the Portfolio Stack example, the value will be found in the Outputs' tab of the stack under "CreatedPortfolioID". + + ProductNameParameter: + Type: String + Default: Serverless Inference Endpoint fronted with API Gateway SageMaker Project Example + Description: The name of this product within the portfolio. + + ProductDescriptionParameter: + Type: String + Default: 'The purpose of this template is to deploy an serverless inference endpoint, given a ModelGroupPackageName from the Amazon SageMaker Model Registry.' + Description: The description of this product within the portfolio. + + ProductOwnerParameter: + Type: String + Default: Product Owner + Description: The owner of this product within the portfolio. + + ProductDistributorParameter: + Type: String + Default: Product Distributor + Description: The distributor of this product within the portfolio. + + ProductSupportDescriptionParameter: + Type: String + Default: Support Description + Description: The support description of this product within the portfolio. + + ProductSupportEmailParameter: + Type: String + Default: support@example.com + Description: The support email of this product within the portfolio. + + ProductSupportURLParameter: + Type: String + Default: 'https://github.com/aws-samples/sagemaker-custom-project-templates' + Description: The support url of this product within the portfolio. + + SageMakerProjectRepoZipParameter: + Type: String + Default: 'https://github.com/aws-samples/sagemaker-custom-project-templates/archive/refs/heads/main.zip' + Description: 'URL for a Zip of the SageMaker Projects Examples GitHub Repo' + + SageMakerProjectRepoNameBranchParameter: + Type: String + Default: 'sagemaker-custom-project-templates-main' + Description: 'Name/Branch of the SageMaker Projects Examples GitHub Repo' + + SageMakerProjectsProjectNameParameter: + Type: String + Default: api-gateway-serverless + Description: Project folder inside of the GitHub repo for this project + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - + Label: + default: "Service Catalog Portfolio Information" + Parameters: + - PortfolioIDParameter + - + Label: + default: "Service Catalog Product Information" + Parameters: + - ProductNameParameter + - ProductDescriptionParameter + - ProductOwnerParameter + - ProductDistributorParameter + - + Label: + default: "Service Catalog Product Support Information" + Parameters: + - ProductSupportDescriptionParameter + - ProductSupportEmailParameter + - ProductSupportURLParameter + - + Label: + default: "Source Code Repository Configuration (leave defaults if not forking the repository)" + Parameters: + - SageMakerProjectRepoZipParameter + - SageMakerProjectRepoNameBranchParameter + - SageMakerProjectsProjectNameParameter + + ParameterLabels: + + PortfolioIDParameter: + default: 'Portfolio ID' + + ProductNameParameter: + default: 'Product Name' + + ProductDescriptionParameter: + default: 'Product Description' + + ProductOwnerParameter: + default: 'Product Owner' + + ProductDistributorParameter: + default: 'Product Distributor' + + ProductSupportDescriptionParameter: + default: 'Product Support Description' + + ProductSupportEmailParameter: + default: 'Product Support Email' + + ProductSupportURLParameter: + default: 'Product Support URL' + + SageMakerProjectRepoZipParameter: + default: 'URL to the zipped version of your GitHub Repository' + + SageMakerProjectRepoNameBranchParameter: + default: 'Name and branch of your GitHub Repository, should match the root folder of the zip' + + SageMakerProjectsProjectNameParameter: + default: 'Project folder inside of the main repository for this project.' + +Resources: + + BootstrapS3Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Join ['-',['sm-project-api-gateway-serverless-example', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId']]]]]] + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: 'AES256' + + BootstrapS3BucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref BootstrapS3Bucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + AWS: + - !GetAtt ServiceCatalogProductLaunchRole.Arn + Action: 's3:GetObject' + Resource: !Join ['',[!GetAtt BootstrapS3Bucket.Arn,'/*']] + + BootstrapLambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Join ['-', ['SMCustomProject-api-gateway-Bootstrap-Role', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId']]]]]] + Description: Role used for launching the lambda function to bootstrap creation of the Serverless Endpoint fronted with API Gateway SageMaker Custom Project Template example + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: 'sts:AssumeRole' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: BootstrapLambdaExecutionPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - 's3:PutObject' + - 's3:GetObject' + Resource: !Join ['',[!GetAtt BootstrapS3Bucket.Arn,'/*']] + + ServiceCatalogProduct: + Type: AWS::ServiceCatalog::CloudFormationProduct + Properties: + Description: !Ref ProductDescriptionParameter + Distributor: !Ref ProductDistributorParameter + Name: !Ref ProductNameParameter + Owner: !Ref ProductOwnerParameter + ProvisioningArtifactParameters: + - + Description: Base Version + DisableTemplateValidation: false + Info: + LoadTemplateFromURL: !GetAtt InvokeCustomLambda.template_url + Name: v1.0 + SupportDescription: !Ref ProductSupportDescriptionParameter + SupportEmail: !Ref ProductSupportEmailParameter + SupportUrl: !Ref ProductSupportURLParameter + Tags: + - + Key: sagemaker:studio-visibility + Value: 'true' + + ServiceCatalogProductAssociation: + Type: AWS::ServiceCatalog::PortfolioProductAssociation + Properties: + PortfolioId: !Ref PortfolioIDParameter + ProductId: !Ref ServiceCatalogProduct + + ServiceCatalogProductLaunchRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - servicecatalog.amazonaws.com + Action: 'sts:AssumeRole' + Description: Role to use for launching the api-gateway-serverless SageMaker Project example. + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSageMakerAdmin-ServiceCatalogProductsServiceRolePolicy + Policies: + - PolicyName: SM-Serverless-Example-Launch-Policy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - 's3:PutObject' + - 's3:GetObject' + Resource: !Join ['',[!GetAtt BootstrapS3Bucket.Arn,'/*']] + - Effect: Allow + Action: + - 'iam:PassRole' + Resource: + - !GetAtt CustomProjectServiceCatalogUseRole.Arn + # Permissions for updating project. See https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-projects-update.html + - Effect: Allow + Action: + - 'cloudformation:CreateChangeSet' + - 'cloudformation:DeleteChangeSet' + - 'cloudformation:DescribeChangeSet' + Resource: 'arn:aws:cloudformation:*:*:stack/SC-*' + - Effect: Allow + Action: + - 'codecommit:PutRepositoryTriggers' + Resource: 'arn:aws:codecommit:*:*:sagemaker-*' + RoleName: !Join ['-', ['SM-APIGW-Serverless-Launch-Role', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId']]]]]] + + ServiceCatalogProductRoleLaunchContstraint: + Type: AWS::ServiceCatalog::LaunchRoleConstraint + DependsOn: + - ServiceCatalogProductAssociation + Properties: + Description: Role for launching the API gateway serverless endpoint product + PortfolioId: !Ref PortfolioIDParameter + ProductId: !Ref ServiceCatalogProduct + RoleArn: !GetAtt ServiceCatalogProductLaunchRole.Arn + + CustomProjectServiceCatalogUseRole: + Type: AWS::IAM::Role + Properties: + RoleName: SMCustomProject-APIGW-Serverless-SCUseRole + Description: Role used for by components of the the api-gateway-serverless SageMaker Custom Project Template example. Has extra API gateway permissions comparing to built-in UseRole. + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - cloudformation.amazonaws.com + - apigateway.amazonaws.com + - lambda.amazonaws.com + - sagemaker.amazonaws.com + - codebuild.amazonaws.com + - glue.amazonaws.com + - states.amazonaws.com + - events.amazonaws.com + - codepipeline.amazonaws.com + - firehose.amazonaws.com + Action: 'sts:AssumeRole' + + CustomProjectServiceCatalogUsePolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: SMCustomProject-APIGW-Serverlesss-SCUsePolicy + Roles: + - !Ref CustomProjectServiceCatalogUseRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Action: + - 'apigateway:*' + Effect: Allow + Resource: + - 'arn:aws:apigateway:*::/restapis' + - 'arn:aws:apigateway:*::/restapis/*' + - 'arn:aws:apigateway:*::/tags*' + - Action: + - 'iam:AttachRolePolicy' + - 'iam:CreateRole' + - 'iam:CreatePolicy' + - 'iam:CreatePolicyVersion' + - 'iam:DeletePolicy' + - 'iam:DeletePolicyVersion' + - 'iam:DeleteRole' + - 'iam:DeleteRolePolicy' + - 'iam:DetachRolePolicy' + - 'iam:GetPolicy' + - 'iam:GetPolicyVersion' + - 'iam:GetRole' + - 'iam:GetRolePolicy' + - 'iam:ListAttachedRolePolicies' + - 'iam:ListPolicies' + - 'iam:ListPolicyVersions' + - 'iam:ListRolePolicies' + - 'iam:PutRolePolicy' + Effect: Allow + Resource: '*' + - Effect: Allow + Action: + - 'iam:PassRole' + Resource: !Join [ ':', [ 'arn', !Ref 'AWS::Partition', 'iam:', !Ref 'AWS::AccountId', 'role/ApiGatewayInvokeSageMakerEndpointRole-*'] ] + # Below are default roles from the built-in template + - Action: + - 'cloudformation:CreateChangeSet' + - 'cloudformation:CreateStack' + - 'cloudformation:DescribeChangeSet' + - 'cloudformation:DeleteChangeSet' + - 'cloudformation:DeleteStack' + - 'cloudformation:DescribeStacks' + - 'cloudformation:ExecuteChangeSet' + - 'cloudformation:SetStackPolicy' + - 'cloudformation:UpdateStack' + Effect: Allow + Resource: 'arn:aws:cloudformation:*:*:stack/sagemaker-*' + - Action: + - 'cloudwatch:PutMetricData' + Effect: Allow + Resource: '*' + - Action: + - 'codebuild:BatchGetBuilds' + - 'codebuild:StartBuild' + Effect: Allow + Resource: + - 'arn:aws:codebuild:*:*:project/sagemaker-*' + - 'arn:aws:codebuild:*:*:build/sagemaker-*' + - Action: + - 'codecommit:CancelUploadArchive' + - 'codecommit:GetBranch' + - 'codecommit:GetCommit' + - 'codecommit:GetUploadArchiveStatus' + - 'codecommit:UploadArchive' + Effect: Allow + Resource: 'arn:aws:codecommit:*:*:sagemaker-*' + - Action: + - 'codepipeline:StartPipelineExecution' + Effect: Allow + Resource: 'arn:aws:codepipeline:*:*:sagemaker-*' + - Action: + - 'ec2:DescribeRouteTables' + Effect: Allow + Resource: '*' + - Action: + - 'ecr:BatchCheckLayerAvailability' + - 'ecr:BatchGetImage' + - 'ecr:Describe*' + - 'ecr:GetAuthorizationToken' + - 'ecr:GetDownloadUrlForLayer' + Effect: Allow + Resource: '*' + - Action: + - 'ecr:BatchDeleteImage' + - 'ecr:CompleteLayerUpload' + - 'ecr:CreateRepository' + - 'ecr:DeleteRepository' + - 'ecr:InitiateLayerUpload' + - 'ecr:PutImage' + - 'ecr:UploadLayerPart' + Effect: Allow + Resource: + - 'arn:aws:ecr:*:*:repository/sagemaker-*' + - Action: + - 'events:DeleteRule' + - 'events:DescribeRule' + - 'events:PutRule' + - 'events:PutTargets' + - 'events:RemoveTargets' + Effect: Allow + Resource: + - 'arn:aws:events:*:*:rule/sagemaker-*' + - Action: + - 'firehose:PutRecord' + - 'firehose:PutRecordBatch' + Effect: Allow + Resource: 'arn:aws:firehose:*:*:deliverystream/sagemaker-*' + - Action: + - 'glue:BatchCreatePartition' + - 'glue:BatchDeletePartition' + - 'glue:BatchDeleteTable' + - 'glue:BatchDeleteTableVersion' + - 'glue:BatchGetPartition' + - 'glue:CreateDatabase' + - 'glue:CreatePartition' + - 'glue:CreateTable' + - 'glue:DeletePartition' + - 'glue:DeleteTable' + - 'glue:DeleteTableVersion' + - 'glue:GetDatabase' + - 'glue:GetPartition' + - 'glue:GetPartitions' + - 'glue:GetTable' + - 'glue:GetTables' + - 'glue:GetTableVersion' + - 'glue:GetTableVersions' + - 'glue:SearchTables' + - 'glue:UpdatePartition' + - 'glue:UpdateTable' + Effect: Allow + Resource: + - 'arn:aws:glue:*:*:catalog' + - 'arn:aws:glue:*:*:database/default' + - 'arn:aws:glue:*:*:database/global_temp' + - 'arn:aws:glue:*:*:database/sagemaker-*' + - 'arn:aws:glue:*:*:table/sagemaker-*' + - 'arn:aws:glue:*:*:tableVersion/sagemaker-*' + - Action: + - 'iam:PassRole' + Effect: Allow + Resource: + - !GetAtt CustomProjectServiceCatalogUseRole.Arn + - Action: + - 'lambda:InvokeFunction' + Effect: Allow + Resource: + - 'arn:aws:lambda:*:*:function:sagemaker-*' + - Action: + - 'logs:CreateLogDelivery' + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:DeleteLogDelivery' + - 'logs:Describe*' + - 'logs:GetLogDelivery' + - 'logs:GetLogEvents' + - 'logs:ListLogDeliveries' + - 'logs:PutLogEvents' + - 'logs:PutResourcePolicy' + - 'logs:UpdateLogDelivery' + Effect: Allow + Resource: '*' + - Action: + - 's3:CreateBucket' + - 's3:DeleteBucket' + - 's3:GetBucketAcl' + - 's3:GetBucketCors' + - 's3:GetBucketLocation' + - 's3:ListAllMyBuckets' + - 's3:ListBucket' + - 's3:ListBucketMultipartUploads' + - 's3:PutBucketCors' + - 's3:PutObjectAcl' + Effect: Allow + Resource: + - 'arn:aws:s3:::aws-glue-*' + - 'arn:aws:s3:::sagemaker-*' + - Action: + - 's3:AbortMultipartUpload' + - 's3:DeleteObject' + - 's3:GetObject' + - 's3:GetObjectVersion' + - 's3:PutObject' + Effect: Allow + Resource: + - 'arn:aws:s3:::aws-glue-*' + - 'arn:aws:s3:::sagemaker-*' + - Action: + - 'sagemaker:*' + Effect: Allow + NotResource: + - 'arn:aws:sagemaker:*:*:domain/*' + - 'arn:aws:sagemaker:*:*:user-profile/*' + - 'arn:aws:sagemaker:*:*:app/*' + - 'arn:aws:sagemaker:*:*:flow-definition/*' + - Action: + - 'states:DescribeExecution' + - 'states:DescribeStateMachine' + - 'states:DescribeStateMachineForExecution' + - 'states:GetExecutionHistory' + - 'states:ListExecutions' + - 'states:ListTagsForResource' + - 'states:StartExecution' + - 'states:StopExecution' + - 'states:TagResource' + - 'states:UntagResource' + - 'states:UpdateStateMachine' + Effect: Allow + Resource: + - 'arn:aws:states:*:*:stateMachine:sagemaker-*' + - 'arn:aws:states:*:*:execution:sagemaker-*:*' + - Action: + - 'states:ListStateMachines' + Effect: Allow + Resource: '*' + - Action: + - 'codestar-connections:UseConnection' + Condition: + StringEqualsIgnoreCase: + aws:ResourceTag/sagemaker: 'true' + Effect: Allow + Resource: 'arn:aws:codestar-connections:*:*:connection/*' + + + CustomBackedLambda: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Join ['-', ['aws-samples-api-gateway-lambda-bootstrapper', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId']]]]]] + Runtime: python3.9 + Role: !GetAtt BootstrapLambdaExecutionRole.Arn + Handler: index.lambda_handler + Timeout: 300 + Code: + ZipFile: | + import cfnresponse + import logging + import random + import json + import urllib3 + import os + import zipfile + import pathlib + import boto3 + + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + s3_client = boto3.client('s3') + + def lambda_handler(event, context): + try: + logger.info('Incoming Request:') + logger.info(json.dumps(event)) + + if event.get('RequestType') == 'Create' or event.get('RequestType') == 'Update': + + required_variables = ['BootStrapBucketName','SageMakerProjectRepoZip','SageMakerProjectRepoNameBranch', 'SageMakerProjectsProjectName'] + + if not set(required_variables).issubset(set(event['ResourceProperties'])): + raise Exception(f'Missing required input from: {required_variables}' ) + + sagemaker_projects_repo = event['ResourceProperties']['SageMakerProjectRepoZip'] + sagemaker_projects_repo_root_folder_name = event['ResourceProperties']['SageMakerProjectRepoNameBranch'] + sagemaker_projects_project_name = event['ResourceProperties']['SageMakerProjectsProjectName'] + + logger.info(f'begin fetch of zipped rep from github: {sagemaker_projects_repo}') + + chunk_size = 6 * 1024 + http = urllib3.PoolManager() + + r = http.request('GET', sagemaker_projects_repo, preload_content=False) + + with open('/tmp/sm_project_repo.zip', 'wb') as out: + while True: + data = r.read(chunk_size) + if not data: + break + out.write(data) + + r.release_conn() + + logger.info(f'github zip written to /tmp/sm_project_repo.zip') + + local_extracted_path_root = '/tmp/' + sagemaker_projects_repo_root_folder_name + + logger.info(f'extracting github zip to {local_extracted_path_root}') + + with zipfile.ZipFile('/tmp/sm_project_repo.zip', 'r') as zip_ref: + zip_ref.extractall('/tmp') + + logger.info(f'github zip extracted to {local_extracted_path_root}') + + logger.info(f'begin project template customizations') + + bootstrap_bucket_name = event['ResourceProperties']['BootStrapBucketName'] + + input_file_path = local_extracted_path_root + '/' + sagemaker_projects_project_name + '/project/template.yaml' + output_file_path = local_extracted_path_root + '/' + sagemaker_projects_project_name + '/project/template_updated.yaml' + s3_template_key = sagemaker_projects_project_name + '/templates/template.yaml' + + with open(input_file_path, 'r') as input_template_file: + with open(output_file_path, 'w') as output_template_file: + file_data = input_template_file.read() + + if 'TemplateSubstitutions' in event['ResourceProperties'].keys(): + for replacement_key in event['ResourceProperties']['TemplateSubstitutions'].keys(): + file_data = file_data.replace(replacement_key, event['ResourceProperties']['TemplateSubstitutions'][replacement_key]) + + output_template_file.write(file_data) + + with open(output_file_path, 'rb') as file_data: + s3_client.upload_fileobj(file_data, bootstrap_bucket_name, s3_template_key) + + logger.info(f'completed project template customizations') + + logger.info(f'begin building and uploading sub archives from seedcode and lambda folders') + + archive_info_list = [] + + if 'Archives' in event['ResourceProperties'].keys(): + for archive in event['ResourceProperties']['Archives']: + archive_info_list.append([archive[0], pathlib.Path(local_extracted_path_root + '/' + sagemaker_projects_project_name + archive[1])]) + + for archive_info in archive_info_list: + + target_filename = '/tmp/' + archive_info[0] + s3_object_key = sagemaker_projects_project_name + '/seedcode/' + archive_info[0] + source_dir = archive_info[1] + + logger.info(f'source folder: {(source_dir)}') + logger.info(f'begin creating archive: {target_filename}') + + with zipfile.ZipFile(target_filename, mode='w') as archive: + for file_path in source_dir.rglob('*'): + archive.write( + file_path, + arcname=file_path.relative_to(source_dir) + ) + + archive.close() + + logger.info(f'completed creating zip file: {target_filename}') + + logger.info(f'uploading zip file: {target_filename} as s3 object: {bootstrap_bucket_name}/{s3_object_key}') + + with open(target_filename, 'rb') as file_data: + s3_client.upload_fileobj(file_data, bootstrap_bucket_name, s3_object_key) + + logger.info(f'upload for s3 object: {bootstrap_bucket_name}/{s3_object_key} complete') + + logger.info(f'completed building and uploading sub archives from seedcode and lambda folders') + + logger.info(f'generating presigned url for template') + + signed_template_url = s3_client.generate_presigned_url('get_object', + Params={'Bucket': bootstrap_bucket_name,'Key': s3_template_key}, + ExpiresIn=6000) + + logger.info(f"generated presigned url for template successfully: {signed_template_url}") + + message = 'Create Invoked Successfully' + responseData = {} + responseData['message'] = message + responseData['template_url'] = signed_template_url + logger.info('Sending %s to cloudformation', responseData['message']) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) + elif event.get('RequestType') == 'Delete': + responseData = {} + responseData['message'] = "Invoking Delete" + logger.info('Sending %s to cloudformation', responseData['message']) + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) + else: + logger.error('Unknown operation: %s', event.get('RequestType')) + raise Exception("Unknown operation") + + except Exception as e: + logger.exception(e) + responseData = {} + responseData['message'] = 'exception caught' + cfnresponse.send(event, context, cfnresponse.FAILED, responseData) + Description: Cloudformation Custom Lambda to bootstrap the Serverless Endpoint fronted SageMaker Project example + + InvokeCustomLambda: + Type: Custom::InvokeCustomLambda + Properties: + ServiceToken: !GetAtt CustomBackedLambda.Arn + BootStrapBucketName: !Ref BootstrapS3Bucket + SageMakerProjectRepoZip: !Ref SageMakerProjectRepoZipParameter + SageMakerProjectRepoNameBranch: !Ref SageMakerProjectRepoNameBranchParameter + SageMakerProjectsProjectName: !Ref SageMakerProjectsProjectNameParameter + TemplateSubstitutions: + AWSDEFAULT___CODE_STAGING_BUCKET___ : !Ref BootstrapS3Bucket + AWSDEFAULT___PROJECT_NAME___ : !Ref SageMakerProjectsProjectNameParameter + AWSDEFAULT___PROJECT_USE_ROLE_ARN___: !GetAtt CustomProjectServiceCatalogUseRole.Arn + Archives: + - ['api-gateway-serverless-deploy.zip','/seedcode/deploy'] + + +Outputs: + CreatedServiceCatalogProductName: + Description: Name of the newly created product. + Value: !GetAtt ServiceCatalogProduct.ProductName + Export: + Name: !Join [':', [!Ref 'AWS::StackName', 'CreatedServiceCatalogProductName'] ] + + CreatedServiceCatalogProductId: + Description: Id of the newly created product. + Value: !Ref ServiceCatalogProduct + Export: + Name: !Join [':', [!Ref 'AWS::StackName', 'CreatedServiceCatalogProductId'] ] + + AssociatedServiceCatalogPortfolioID: + Description: ID of the associated Service Catalog Portfolio. + Value: !Ref PortfolioIDParameter + Export: + Name: !Join [':', [!Ref 'AWS::StackName', 'AssociatedServiceCatalogPortfolioID'] ] + + ServiceCatalogProductLaunchRoleARN: + Description: ARN of the Role used to launch this product + Value: !Join [ ':', [ 'arn', !Ref 'AWS::Partition', 'iam:', !Ref 'AWS::AccountId', 'role/service-role/AmazonSageMakerServiceCatalogProductsLaunchRole'] ] + Export: + Name: !Join [':', [!Ref 'AWS::StackName', 'ServiceCatalogProductLaunchRoleARN'] ] + + CodeStagingBucketName: + Description: Name of the S3 Bucket containing the staging code for the repository. + Value: !Ref BootstrapS3Bucket + Export: + Name: !Join [':', [!Ref 'AWS::StackName', 'CodeStagingBucketName']] diff --git a/api-gateway-serverless/images/api-gateway-serverless-architecture.svg b/api-gateway-serverless/images/api-gateway-serverless-architecture.svg new file mode 100644 index 00000000..eef25fde --- /dev/null +++ b/api-gateway-serverless/images/api-gateway-serverless-architecture.svg @@ -0,0 +1,4 @@ + + + +ModelDeploy (CD Pipeline)Regiser Model
SageMaker Model Registry
SageMaker...
Approve Deployment
Approve Deployment
CodePipeline
CodePipeline
CodeCommit
(Deploy Code)
CodeCommit...
CodeBuild
(Build CloudFormation
Deployment Templates)
CodeBuild...
CloudFormation
CloudFormation
Deploy to Staging
Deploy to...
Manual
Approval
Manual...
CloudFormation
CloudFormation
Deploy to Staging
Deploy to...
SageMaker
Serverless Endpoint
(Staging)
SageMaker...
SageMaker
Serverless Endpoint
(Prod)
SageMaker...
API Gateway
(Staging)
API Gateway...
API Gateway
(Prod)
API Gateway...
REST API Client
REST API Clie...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/api-gateway-serverless/images/serverless-create-project.png b/api-gateway-serverless/images/serverless-create-project.png new file mode 100644 index 00000000..8c350899 Binary files /dev/null and b/api-gateway-serverless/images/serverless-create-project.png differ diff --git a/api-gateway-serverless/images/serverless-params-01.png b/api-gateway-serverless/images/serverless-params-01.png new file mode 100644 index 00000000..f1abafc6 Binary files /dev/null and b/api-gateway-serverless/images/serverless-params-01.png differ diff --git a/api-gateway-serverless/images/serverless-params-02.png b/api-gateway-serverless/images/serverless-params-02.png new file mode 100644 index 00000000..427dbfb7 Binary files /dev/null and b/api-gateway-serverless/images/serverless-params-02.png differ diff --git a/api-gateway-serverless/images/serverless-params-03.png b/api-gateway-serverless/images/serverless-params-03.png new file mode 100644 index 00000000..706b80bf Binary files /dev/null and b/api-gateway-serverless/images/serverless-params-03.png differ diff --git a/api-gateway-serverless/images/serverless-params-04.png b/api-gateway-serverless/images/serverless-params-04.png new file mode 100644 index 00000000..338e333a Binary files /dev/null and b/api-gateway-serverless/images/serverless-params-04.png differ diff --git a/api-gateway-serverless/project/template.yaml b/api-gateway-serverless/project/template.yaml new file mode 100644 index 00000000..e92c5f6f --- /dev/null +++ b/api-gateway-serverless/project/template.yaml @@ -0,0 +1,276 @@ +Description: Toolchain template which provides the resources needed to represent infrastructure as code. + This template specifically creates a CI/CD pipeline to deploy a given inference image and pretrained Model to two stages in CD -- staging and production. +Parameters: + SageMakerProjectName: + Type: String + Description: Name of the project + MinLength: 1 + MaxLength: 32 + AllowedPattern: ^[a-zA-Z](-*[a-zA-Z0-9])* + + SageMakerProjectId: + Type: String + Description: Service generated Id of the project. + + SourceModelPackageGroupName: + Type: String + Description: Name of the ModelPackageGroup for which deployments should be triggered for + + UseRoleArn: + Type: String + Default: AWSDEFAULT___PROJECT_USE_ROLE_ARN___ + Description: Role ARN used by the project to invoke services on your behalf. + +Resources: + + MlOpsArtifactsBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + Properties: + BucketName: !Sub sagemaker-project-${SageMakerProjectId} # 58 chars max/ 64 allowed + + ModelDeploySageMakerEventRule: + Type: AWS::Events::Rule + Properties: + # Max length allowed: 64 + Name: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-model # max: 10+33+15+5=63 chars + Description: "Rule to trigger a deployment when SageMaker Model registry is updated with a new model package. For example, a new model package is registered with Registry" + EventPattern: + source: + - "aws.sagemaker" + detail-type: + - "SageMaker Model Package State Change" + detail: + ModelPackageGroupName: + - !Ref SourceModelPackageGroupName + State: "ENABLED" + Targets: + - + Arn: + !Join [ ':', [ 'arn', !Ref 'AWS::Partition', 'codepipeline', !Ref 'AWS::Region', !Ref 'AWS::AccountId', !Ref ModelDeployPipeline ] ] + RoleArn: + !Ref UseRoleArn + Id: !Sub sagemaker-${SageMakerProjectName}-trigger + + ModelDeployCodeCommitEventRule: + Type: AWS::Events::Rule + Properties: + # Max length allowed: 64 + Name: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-code # max: 10+33+15+4=62 chars + Description: "Rule to trigger a deployment when CodeCommit is updated with a commit" + EventPattern: + source: + - "aws.codecommit" + detail-type: + - "CodeCommit Repository State Change" + resources: + - !GetAtt ModelDeployCodeCommitRepository.Arn + detail: + referenceType: + - "branch" + referenceName: + - "main" + State: "ENABLED" + Targets: + - + Arn: + !Join [ ':', [ 'arn', !Ref 'AWS::Partition', 'codepipeline', !Ref 'AWS::Region', !Ref 'AWS::AccountId', !Ref ModelDeployPipeline ] ] + RoleArn: + !Ref UseRoleArn + Id: !Sub codecommit-${SageMakerProjectName}-trigger + + ModelDeployCodeCommitRepository: + Type: AWS::CodeCommit::Repository + Properties: + # Max allowed length: 100 chars + RepositoryName: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-modeldeploy # max: 10+33+15+11=69 + RepositoryDescription: !Sub SageMaker Endpoint deployment infrastructure as code for the Project ${SageMakerProjectName} + Code: + S3: + Bucket: AWSDEFAULT___CODE_STAGING_BUCKET___ + Key: AWSDEFAULT___PROJECT_NAME___/seedcode/api-gateway-serverless-deploy.zip + BranchName: main + + ModelDeployBuildProject: + Type: AWS::CodeBuild::Project + Properties: + # Max length: 255 chars + Name: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-modeldeploy # max: 10+33+15+11=69 + Description: Builds the Cfn template which defines the Endpoint with specified configuration + ServiceRole: + !Ref UseRoleArn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_SMALL + Image: aws/codebuild/amazonlinux2-x86_64-standard:3.0 + EnvironmentVariables: + - Name: SAGEMAKER_PROJECT_NAME + Value: !Ref SageMakerProjectName + - Name: SAGEMAKER_PROJECT_ID + Value: !Ref SageMakerProjectId + - Name: ARTIFACT_BUCKET + Value: !Ref MlOpsArtifactsBucket + - Name: MODEL_EXECUTION_ROLE_ARN + Value: !Ref UseRoleArn + + - Name: SAGEMAKER_PROJECT_ARN + Value: !Join [ ':', [ 'arn', !Ref 'AWS::Partition', 'sagemaker', !Ref 'AWS::Region', !Ref 'AWS::AccountId', !Sub 'project/${SageMakerProjectName}']] + - Name: SOURCE_MODEL_PACKAGE_GROUP_NAME + Value: !Ref SourceModelPackageGroupName + - Name: AWS_REGION + Value: !Ref AWS::Region + # these values are used by the build system to output to the output artifacts. + # further down, we use these names in the Cfn deployment steps + - Name: EXPORT_TEMPLATE_NAME + Value: template-export.yml + - Name: EXPORT_TEMPLATE_STAGING_CONFIG + Value: staging-config-export.json + - Name: EXPORT_TEMPLATE_PROD_CONFIG + Value: prod-config-export.json + Source: + Type: CODEPIPELINE + BuildSpec: buildspec.yml + TimeoutInMinutes: 30 + + ModelDeployTestProject: + Type: AWS::CodeBuild::Project + Properties: + # Max length: 255 chars + Name: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-testing # max: 10+33+15+7=65 + Description: Test the deployment endpoint + ServiceRole: + !Ref UseRoleArn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: LINUX_CONTAINER + ComputeType: BUILD_GENERAL1_SMALL + Image: "aws/codebuild/amazonlinux2-x86_64-standard:3.0" + EnvironmentVariables: + - Name: SAGEMAKER_PROJECT_NAME + Value: !Ref SageMakerProjectName + - Name: SAGEMAKER_PROJECT_ID + Value: !Ref SageMakerProjectId + - Name: AWS_REGION + Value: !Ref "AWS::Region" + - Name: BUILD_CONFIG + Value: staging-config-export.json + - Name: EXPORT_TEST_RESULTS + Value: test-results.json + Source: + Type: CODEPIPELINE + BuildSpec: test/buildspec.yml + TimeoutInMinutes: 30 + + ModelDeployPipeline: + Type: AWS::CodePipeline::Pipeline + DependsOn: MlOpsArtifactsBucket + Properties: + # Max length: 100 chars + Name: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-modeldeploy # max: 10+33+15+11=69 + RoleArn: + !Ref UseRoleArn + ArtifactStore: + Type: S3 + Location: + !Ref MlOpsArtifactsBucket + Stages: + - Name: Source + Actions: + - Name: ModelDeployInfraCode + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeCommit + Version: 1 + Configuration: + # need to explicitly set this to false per https://docs.aws.amazon.com/codepipeline/latest/userguide/update-change-detection.html + PollForSourceChanges: false + RepositoryName: !GetAtt ModelDeployCodeCommitRepository.Name + BranchName: main + OutputArtifacts: + - Name: SourceArtifact + + - Name: Build + Actions: + - Name: BuildDeploymentTemplates + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: 1 + InputArtifacts: + - Name: SourceArtifact + OutputArtifacts: + - Name: BuildArtifact + Configuration: + ProjectName: !Ref ModelDeployBuildProject + RunOrder: 1 + + - Name: DeployStaging + Actions: + - Name: DeployResourcesStaging + InputArtifacts: + - Name: BuildArtifact + ActionTypeId: + Category: Deploy + Owner: AWS + Version: 1 + Provider: CloudFormation + Configuration: + ActionMode: REPLACE_ON_FAILURE + Capabilities: CAPABILITY_NAMED_IAM + RoleArn: + !Ref UseRoleArn + StackName: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-deploy-staging #10+33+15+14=72 out of 128 max + TemplateConfiguration: BuildArtifact::staging-config-export.json + # The buildspec.yml in the application stack uses this file name, + TemplatePath: BuildArtifact::template-export.yml + RunOrder: 1 + - Name: TestStaging + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: 1 + InputArtifacts: + - Name: SourceArtifact + - Name: BuildArtifact + OutputArtifacts: + - Name: TestArtifact + Configuration: + ProjectName: !Ref ModelDeployTestProject + PrimarySource: SourceArtifact + RunOrder: 2 + - Name: ApproveDeployment + ActionTypeId: + Category: Approval + Owner: AWS + Version: 1 + Provider: Manual + Configuration: + CustomData: "Approve this model for Production" + RunOrder: 3 + - Name: DeployProd + Actions: + - Name: DeployResourcesProd + InputArtifacts: + - Name: BuildArtifact + ActionTypeId: + Category: Deploy + Owner: AWS + Version: 1 + Provider: CloudFormation + Configuration: + ActionMode: CREATE_UPDATE + RoleArn: + !Ref UseRoleArn + Capabilities: CAPABILITY_NAMED_IAM + #CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND + StackName: !Sub sagemaker-${SageMakerProjectName}-${SageMakerProjectId}-deploy-prod #10+33+15+11=69 out of 128 max + TemplateConfiguration: BuildArtifact::prod-config-export.json + TemplatePath: BuildArtifact::template-export.yml + RunOrder: 1 + diff --git a/api-gateway-serverless/seedcode/deploy/.gitignore b/api-gateway-serverless/seedcode/deploy/.gitignore new file mode 100644 index 00000000..b05de3db --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/.gitignore @@ -0,0 +1 @@ +.sagemaker-code-config diff --git a/api-gateway-serverless/seedcode/deploy/README.md b/api-gateway-serverless/seedcode/deploy/README.md new file mode 100644 index 00000000..9aafc3ec --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/README.md @@ -0,0 +1,35 @@ +## MLOps for SageMaker Endpoint Deployment + +This is a sample code repository for demonstrating how you can organize your code for deploying an realtime inference Endpoint infrastructure. This code repository is created as part of creating a Project in SageMaker. + +This code repository has the code to find the latest approved ModelPackage for the associated ModelPackageGroup and automaticaly deploy it to the Endpoint on detecting a change (`build.py`). This code repository also defines the CloudFormation template which defines the Endpoints as infrastructure. It also has configuration files associated with `staging` and `prod` stages. + +Upon triggering a deployment, the CodePipeline pipeline will deploy 2 Endpoints - `staging` and `prod`. After the first deployment is completed, the CodePipeline waits for a manual approval step for promotion to the prod stage. You will need to go to CodePipeline AWS Managed Console to complete this step. + +You own this code and you can modify this template to change as you need it, add additional tests for your custom validation. + +A description of some of the artifacts is provided below: + + +## Layout of the SageMaker ModelBuild Project Template + +`buildspec.yml` + - this file is used by the CodePipeline's Build stage to build a CloudFormation template. + +`build.py` + - this python file contains code to get the latest approve package arn and exports staging and configuration files. This is invoked from the Build stage. + +`endpoint-config-template.yml` + - this CloudFormation template file is packaged by the build step in the CodePipeline and is deployed in different stages. + +`staging-config.json` + - this configuration file is used to customize `staging` stage in the pipeline. You can configure the instance type, instance count here. + +`prod-config.json` + - this configuration file is used to customize `prod` stage in the pipeline. You can configure the instance type, instance count here. + +`test\buildspec.yml` + - this file is used by the CodePipeline's `staging` stage to run the test code of the following python file + +`test\test.py` + - this python file contains code to describe and invoke the staging endpoint. You can customize to add more tests here. diff --git a/api-gateway-serverless/seedcode/deploy/build.py b/api-gateway-serverless/seedcode/deploy/build.py new file mode 100644 index 00000000..bc5cc42d --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/build.py @@ -0,0 +1,169 @@ +import argparse +import json +import logging +import os + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) +sm_client = boto3.client("sagemaker") + + +def get_approved_package(model_package_group_name): + """Gets the latest approved model package for a model package group. + + Args: + model_package_group_name: The model package group name. + + Returns: + The SageMaker Model Package ARN. + """ + try: + # Get the latest approved model package + response = sm_client.list_model_packages( + ModelPackageGroupName=model_package_group_name, + ModelApprovalStatus="Approved", + SortBy="CreationTime", + MaxResults=100, + ) + approved_packages = response["ModelPackageSummaryList"] + + # Fetch more packages if none returned with continuation token + while len(approved_packages) == 0 and "NextToken" in response: + logger.debug("Getting more packages for token: {}".format(response["NextToken"])) + response = sm_client.list_model_packages( + ModelPackageGroupName=model_package_group_name, + ModelApprovalStatus="Approved", + SortBy="CreationTime", + MaxResults=100, + NextToken=response["NextToken"], + ) + approved_packages.extend(response["ModelPackageSummaryList"]) + + # Return error if no packages found + if len(approved_packages) == 0: + error_message = ( + f"No approved ModelPackage found for ModelPackageGroup: {model_package_group_name}" + ) + logger.error(error_message) + raise Exception(error_message) + + # Return the pmodel package arn + model_package_arn = approved_packages[0]["ModelPackageArn"] + logger.info(f"Identified the latest approved model package: {model_package_arn}") + return model_package_arn + except ClientError as e: + error_message = e.response["Error"]["Message"] + logger.error(error_message) + raise Exception(error_message) + + +def extend_config(args, model_package_arn, stage_config): + """ + Extend the stage configuration with additional parameters and tags based. + """ + # Verify that config has parameters and tags sections + if not "Parameters" in stage_config or not "StageName" in stage_config["Parameters"]: + raise Exception("Configuration file must include SageName parameter") + if not "Tags" in stage_config: + stage_config["Tags"] = {} + # Create new params and tags + new_params = { + "SageMakerProjectName": args.sagemaker_project_name, + "ModelPackageName": model_package_arn, + "ModelExecutionRoleArn": args.model_execution_role, + } + new_tags = { + "sagemaker:deployment-stage": stage_config["Parameters"]["StageName"], + "sagemaker:project-id": args.sagemaker_project_id, + "sagemaker:project-name": args.sagemaker_project_name, + } + # Add tags from Project + get_pipeline_custom_tags(args, sm_client, new_tags) + + return { + "Parameters": {**stage_config["Parameters"], **new_params}, + "Tags": {**stage_config.get("Tags", {}), **new_tags}, + } + +def get_pipeline_custom_tags(args, sm_client, new_tags): + try: + response = sm_client.list_tags( + ResourceArn=args.sagemaker_project_arn) + project_tags = response["Tags"] + for project_tag in project_tags: + new_tags[project_tag["Key"]] = project_tag["Value"] + except: + logger.error("Error getting project tags") + return new_tags + +def get_cfn_style_config(stage_config): + parameters = [] + for key, value in stage_config["Parameters"].items(): + parameter = { + "ParameterKey": key, + "ParameterValue": value + } + parameters.append(parameter) + tags = [] + for key, value in stage_config["Tags"].items(): + tag = { + "Key": key, + "Value": value + } + tags.append(tag) + return parameters, tags + +def create_cfn_params_tags_file(config, export_params_file, export_tags_file): + # Write Params and tags in separate file for Cfn cli command + parameters, tags = get_cfn_style_config(config) + with open(export_params_file, "w") as f: + json.dump(parameters, f, indent=4) + with open(export_tags_file, "w") as f: + json.dump(tags, f, indent=4) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--log-level", type=str, default=os.environ.get("LOGLEVEL", "INFO").upper()) + parser.add_argument("--model-execution-role", type=str, required=True) + parser.add_argument("--model-package-group-name", type=str, required=True) + parser.add_argument("--sagemaker-project-id", type=str, required=True) + parser.add_argument("--sagemaker-project-name", type=str, required=True) + parser.add_argument("--sagemaker-project-arn", type=str, required=False) + parser.add_argument("--s3-bucket", type=str, required=True) + parser.add_argument("--import-staging-config", type=str, default="staging-config.json") + parser.add_argument("--import-prod-config", type=str, default="prod-config.json") + parser.add_argument("--export-staging-config", type=str, default="staging-config-export.json") + parser.add_argument("--export-staging-params", type=str, default="staging-params-export.json") + parser.add_argument("--export-staging-tags", type=str, default="staging-tags-export.json") + parser.add_argument("--export-prod-config", type=str, default="prod-config-export.json") + parser.add_argument("--export-prod-params", type=str, default="prod-params-export.json") + parser.add_argument("--export-prod-tags", type=str, default="prod-tags-export.json") + parser.add_argument("--export-cfn-params-tags", type=bool, default=False) + args, _ = parser.parse_known_args() + + # Configure logging to output the line number and message + log_format = "%(levelname)s: [%(filename)s:%(lineno)s] %(message)s" + logging.basicConfig(format=log_format, level=args.log_level) + + # Get the latest approved package + model_package_arn = get_approved_package(args.model_package_group_name) + + # Write the staging config + with open(args.import_staging_config, "r") as f: + staging_config = extend_config(args, model_package_arn, json.load(f)) + logger.debug("Staging config: {}".format(json.dumps(staging_config, indent=4))) + with open(args.export_staging_config, "w") as f: + json.dump(staging_config, f, indent=4) + if (args.export_cfn_params_tags): + create_cfn_params_tags_file(staging_config, args.export_staging_params, args.export_staging_tags) + + # Write the prod config for code pipeline + with open(args.import_prod_config, "r") as f: + prod_config = extend_config(args, model_package_arn, json.load(f)) + logger.debug("Prod config: {}".format(json.dumps(prod_config, indent=4))) + with open(args.export_prod_config, "w") as f: + json.dump(prod_config, f, indent=4) + if (args.export_cfn_params_tags): + create_cfn_params_tags_file(prod_config, args.export_prod_params, args.export_prod_tags) diff --git a/api-gateway-serverless/seedcode/deploy/buildspec.yml b/api-gateway-serverless/seedcode/deploy/buildspec.yml new file mode 100644 index 00000000..27a399fc --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/buildspec.yml @@ -0,0 +1,29 @@ +version: 0.2 + +phases: + install: + runtime-versions: + python: 3.8 + commands: + # Upgrade AWS CLI to the latest version + - pip install --upgrade --force-reinstall "botocore>1.21.30" "boto3>1.18.30" "awscli>1.20.30" + + build: + commands: + # Export the staging and production configuration files + - python build.py --model-execution-role "$MODEL_EXECUTION_ROLE_ARN" --model-package-group-name "$SOURCE_MODEL_PACKAGE_GROUP_NAME" --sagemaker-project-id "$SAGEMAKER_PROJECT_ID" --sagemaker-project-name "$SAGEMAKER_PROJECT_NAME" --s3-bucket "$ARTIFACT_BUCKET" --export-staging-config $EXPORT_TEMPLATE_STAGING_CONFIG --export-prod-config $EXPORT_TEMPLATE_PROD_CONFIG --sagemaker-project-arn "$SAGEMAKER_PROJECT_ARN" + + # Package the infrastucture as code defined in endpoint-config-template.yml by using AWS CloudFormation. + # Note that the Environment Variables like ARTIFACT_BUCKET, SAGEMAKER_PROJECT_NAME etc,. used below are expected to be setup by the + # CodeBuild resrouce in the infra pipeline (in the ServiceCatalog product) + - aws cloudformation package --template endpoint-config-template.yml --s3-bucket $ARTIFACT_BUCKET --output-template $EXPORT_TEMPLATE_NAME + + # Print the files to verify contents + - cat $EXPORT_TEMPLATE_STAGING_CONFIG + - cat $EXPORT_TEMPLATE_PROD_CONFIG + +artifacts: + files: + - $EXPORT_TEMPLATE_NAME + - $EXPORT_TEMPLATE_STAGING_CONFIG + - $EXPORT_TEMPLATE_PROD_CONFIG diff --git a/api-gateway-serverless/seedcode/deploy/endpoint-config-template.yml b/api-gateway-serverless/seedcode/deploy/endpoint-config-template.yml new file mode 100644 index 00000000..d09ab4bc --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/endpoint-config-template.yml @@ -0,0 +1,111 @@ +Description: + This template is built and deployed by the infrastructure pipeline in various stages (staging/production) as required. + It specifies the resources that need to be created, like the SageMaker Endpoint. It can be extended to include resources like + AutoScalingPolicy, API Gateway, etc,. as required. +Parameters: + SageMakerProjectName: + Type: String + Description: Name of the project + MinLength: 1 + MaxLength: 32 + AllowedPattern: ^[a-zA-Z](-*[a-zA-Z0-9])* + ModelExecutionRoleArn: + Type: String + Description: Execution role used for deploying the model. + ModelPackageName: + Type: String + Description: The trained Model Package Name + StageName: + Type: String + Description: + The name for a project pipeline stage, such as Staging or Prod, for + which resources are provisioned and deployed. + +Resources: + Model: + Type: AWS::SageMaker::Model + Properties: + Containers: + - ModelPackageName: !Ref ModelPackageName + ExecutionRoleArn: !Ref ModelExecutionRoleArn + + EndpointConfig: + Type: AWS::SageMaker::EndpointConfig + Properties: + ProductionVariants: + - InitialVariantWeight: 1.0 + ModelName: !GetAtt Model.ModelName + VariantName: AllTraffic + ServerlessConfig: + MaxConcurrency: 1 + MemorySizeInMB: 1024 + + Endpoint: + Type: AWS::SageMaker::Endpoint + Properties: + EndpointName: !Sub ${SageMakerProjectName}-${StageName} + EndpointConfigName: !GetAtt EndpointConfig.EndpointConfigName + + ApiGatewayInvokeSageMakerEndpointRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub ApiGatewayInvokeSageMakerEndpointRole-${SageMakerProjectName}-${StageName} + Description: Role used by API Gateway to invoke SageMaker endpoints + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - apigateway.amazonaws.com + Action: 'sts:AssumeRole' + + ApiGatewayInvokeSageMakerEndpointPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ApiGatewayInvokeSageMakerEndpointPolicy-${SageMakerProjectName} + Roles: + - !Ref ApiGatewayInvokeSageMakerEndpointRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Action: + - 'sagemaker:InvokeEndpoint' + Effect: Allow + Resource: !Ref Endpoint + + ApiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Description: Expose the SageMaker endpoint as public-facing REST API + EndpointConfiguration: + Types: + - REGIONAL + Name: !Sub ${SageMakerProjectName}-${StageName}-api + + ApiGatewayRootMethod: + Type: AWS::ApiGateway::Method + Properties: + AuthorizationType: NONE + HttpMethod: POST + Integration: + IntegrationHttpMethod: POST + Type: AWS + Credentials: !GetAtt ApiGatewayInvokeSageMakerEndpointRole.Arn + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:runtime.sagemaker:path/endpoints/${EndpointName}/invocations + - EndpointName: !GetAtt Endpoint.EndpointName + IntegrationResponses: + - StatusCode: 200 + MethodResponses: + - StatusCode: 200 + ResourceId: !GetAtt ApiGateway.RootResourceId + RestApiId: !Ref ApiGateway + + ApiGatewayDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - ApiGatewayRootMethod + Properties: + RestApiId: !Ref ApiGateway + StageName: !Sub ${StageName} diff --git a/api-gateway-serverless/seedcode/deploy/prod-config.json b/api-gateway-serverless/seedcode/deploy/prod-config.json new file mode 100644 index 00000000..7a482372 --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/prod-config.json @@ -0,0 +1,5 @@ +{ + "Parameters": { + "StageName": "prod" + } +} diff --git a/api-gateway-serverless/seedcode/deploy/staging-config.json b/api-gateway-serverless/seedcode/deploy/staging-config.json new file mode 100644 index 00000000..8f3a361e --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/staging-config.json @@ -0,0 +1,5 @@ +{ + "Parameters": { + "StageName": "staging" + } +} diff --git a/api-gateway-serverless/seedcode/deploy/test/buildspec.yml b/api-gateway-serverless/seedcode/deploy/test/buildspec.yml new file mode 100644 index 00000000..998622d3 --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/test/buildspec.yml @@ -0,0 +1,18 @@ +version: 0.2 + +phases: + install: + runtime-versions: + python: 3.8 + commands: + - pip install sagemaker numpy + build: + commands: + # Call the test python code + - python test/test.py --import-build-config $CODEBUILD_SRC_DIR_BuildArtifact/staging-config-export.json --export-test-results $EXPORT_TEST_RESULTS + # Show the test results file + - cat $EXPORT_TEST_RESULTS + +artifacts: + files: + - $EXPORT_TEST_RESULTS diff --git a/api-gateway-serverless/seedcode/deploy/test/test.py b/api-gateway-serverless/seedcode/deploy/test/test.py new file mode 100644 index 00000000..a9c66cf9 --- /dev/null +++ b/api-gateway-serverless/seedcode/deploy/test/test.py @@ -0,0 +1,72 @@ +import argparse +import json +import logging +import os + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) +sm_client = boto3.client("sagemaker") + + +def invoke_endpoint(endpoint_name): + """ + Add custom logic here to invoke the endpoint and validate reponse + """ + return {"endpoint_name": endpoint_name, "success": True} + + +def test_endpoint(endpoint_name): + """ + Describe the endpoint and ensure InSerivce, then invoke endpoint. Raises exception on error. + """ + error_message = None + try: + # Ensure endpoint is in service + response = sm_client.describe_endpoint(EndpointName=endpoint_name) + status = response["EndpointStatus"] + if status != "InService": + error_message = f"SageMaker endpoint: {endpoint_name} status: {status} not InService" + logger.error(error_message) + raise Exception(error_message) + + # Output if endpoint has data capture enbaled + endpoint_config_name = response["EndpointConfigName"] + response = sm_client.describe_endpoint_config(EndpointConfigName=endpoint_config_name) + if "DataCaptureConfig" in response and response["DataCaptureConfig"]["EnableCapture"]: + logger.info(f"data capture enabled for endpoint config {endpoint_config_name}") + + # Call endpoint to handle + return invoke_endpoint(endpoint_name) + except ClientError as e: + error_message = e.response["Error"]["Message"] + logger.error(error_message) + raise Exception(error_message) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--log-level", type=str, default=os.environ.get("LOGLEVEL", "INFO").upper()) + parser.add_argument("--import-build-config", type=str, required=True) + parser.add_argument("--export-test-results", type=str, required=True) + args, _ = parser.parse_known_args() + + # Configure logging to output the line number and message + log_format = "%(levelname)s: [%(filename)s:%(lineno)s] %(message)s" + logging.basicConfig(format=log_format, level=args.log_level) + + # Load the build config + with open(args.import_build_config, "r") as f: + config = json.load(f) + + # Get the endpoint name from sagemaker project name + endpoint_name = "{}-{}".format( + config["Parameters"]["SageMakerProjectName"], config["Parameters"]["StageName"] + ) + results = test_endpoint(endpoint_name) + + # Print results and write to file + logger.debug(json.dumps(results, indent=4)) + with open(args.export_test_results, "w") as f: + json.dump(results, f, indent=4)