diff --git a/packages/@aws-cdk/aws-sagemaker/README.md b/packages/@aws-cdk/aws-sagemaker/README.md index ca404babbb155..80583aa455cfe 100644 --- a/packages/@aws-cdk/aws-sagemaker/README.md +++ b/packages/@aws-cdk/aws-sagemaker/README.md @@ -17,6 +17,220 @@ --- -```ts -const sagemaker = require('@aws-cdk/aws-sagemaker'); +Amazon SageMaker provides every developer and data scientist with the ability to build, train, and +deploy machine learning models quickly. Amazon SageMaker is a fully-managed service that covers the +entire machine learning workflow to label and prepare your data, choose an algorithm, train the +model, tune and optimize it for deployment, make predictions, and take action. Your models get to +production faster with much less effort and lower cost. + +## Installation + +Install the module: + +```console +$ npm i @aws-cdk/aws-sagemaker +``` + +Import it into your code: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; +``` + +## Model + +By creating a model, you tell Amazon SageMaker where it can find the model components. This includes +the S3 path where the model artifacts are stored and the Docker registry path for the image that +contains the inference code. The `ContainerDefinition` interface encapsulates both the specification +of model inference code as a `ContainerImage` and an optional set of artifacts as `ModelData`. + +#### Container Images + +Inference code can be stored in the Amazon EC2 Container Registry (Amazon ECR), which is specified +via `ContainerDefinition`'s `image` property which accepts a class that extends the `ContainerImage` +abstract base class. + +##### `EcrImage` + +Reference an image available within ECR: + +```typescript +import * as ecr from '@aws-cdk/aws-ecr'; +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const repository = ecr.Repository.fromRepositoryName(this, 'Repository', 'repo'); +const image = sagemaker.ContainerImage.fromEcrRepository(repository, 'tag'); +``` + +##### `AssetImage` + +Reference a local directory containing a Dockerfile: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; +import * as path from 'path'; + +const image = sagemaker.ContainerImage.fromAsset(this, 'Image', { + directory: path.join('path', 'to', 'Dockerfile', 'directory') +}); +``` + +#### Model Artifacts + +Models are often associated with model artifacts, which are specified via the `modelData` property +which accepts a class that extends the `ModelData` abstract base class. The default is to have no +model artifacts associated with a model. + +##### `S3ModelData` + +Reference an S3 bucket and object key as the artifacts for a model: + +```typescript +import * as s3 from '@aws-cdk/aws-s3'; +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const bucket = new s3.Bucket(this, 'MyBucket'); +const modelData = sagemaker.ModelData.fromBucket(bucket, 'path/to/artifact/file.tar.gz'); +``` + +##### `AssetModelData` + +Reference local model data: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; +import * as path from 'path'; + +const modelData = sagemaker.ModelData.fromAsset(this, 'ModelData', + path.join('path', 'to', 'artifact', 'file.tar.gz')); +``` + +### `Model` + +The `Model` construct associates container images with their optional model data. + +#### Single Container Model + +In the event that a single container is sufficient for your inference use-case, you can define a +single-container model: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const model = new sagemaker.Model(this, 'PrimaryContainerModel', { + container: { + image: image, + modelData: modelData, + } +}); +``` + +#### Inference Pipeline Model + +An inference pipeline is an Amazon SageMaker model that is composed of a linear sequence of two to +five containers that process requests for inferences on data. You use an inference pipeline to +define and deploy any combination of pretrained Amazon SageMaker built-in algorithms and your own +custom algorithms packaged in Docker containers. You can use an inference pipeline to combine +preprocessing, predictions, and post-processing data science tasks. Inference pipelines are fully +managed. To define an inference pipeline, you can provide additional containers for your model via +the `extraContainers` property: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const model = new sagemaker.Model(this, 'InferencePipelineModel', { + container: { + image: image1, modelData: modelData1 + }, + extraContainers: [ + { image: image2, modelData: modelData2 }, + { image: image3, modelData: modelData3 } + ], +}); +``` + +## Model Hosting + +Amazon SageMaker provides model hosting services for model deployment. Amazon SageMaker provides an +HTTPS endpoint where your machine learning model is available to provide inferences. + +### Endpoint Configuration + +In this configuration, you identify one or more models to deploy and the resources that you want +Amazon SageMaker to provision. You define one or more production variants, each of which identifies +a model. Each production variant also describes the resources that you want Amazon SageMaker to +provision. This includes the number and type of ML compute instances to deploy. If you are hosting +multiple models, you also assign a variant weight to specify how much traffic you want to allocate +to each model. For example, suppose that you want to host two models, A and B, and you assign +traffic weight 2 for model A and 1 for model B. Amazon SageMaker distributes two-thirds of the +traffic to Model A, and one-third to model B: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const endpointConfig = new sagemaker.EndpointConfig(this, 'EndpointConfig', { + productionVariant: { + model: modelA, + variantName: 'modelA', + initialVariantWeight: 2.0, + }, + extraProductionVariants: [{ + model: modelB, + variantName: 'variantB', + initialVariantWeight: 1.0, + }] +}); +``` + +### Endpoint + +If you create an endpoint from an `EndpointConfig`, Amazon SageMaker launches the ML compute +instances and deploys the model or models as specified in the configuration. To get inferences from +the model, client applications send requests to the Amazon SageMaker Runtime HTTPS endpoint. For +more information about the API, see the +[InvokeEndpoint](https://docs.aws.amazon.com/sagemaker/latest/dg/API_runtime_InvokeEndpoint.html) +API. Defining an endpoint requires at minimum the associated endpoint configuration: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const endpoint = new sagemaker.Endpoint(this, 'Endpoint', { endpointConfig }); +``` + +### AutoScaling + + +The `autoScaleInstanceCount` method on the `IEndpointProductionVariant` interface can be used to +enable Application Auto Scaling for the production variant: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); +const productionVariant = endpoint.findProductionVariant('variantName'); +const instanceCount = productionVariant.autoScaleInstanceCount({ + maxCapacity: 3 +}); +instanceCount.scaleOnInvocations('LimitRPS', { + maxRequestsPerSecond: 30, +}); +``` + +For load testing guidance on determining the maximum requests per second per instance, please see +this [documentation](https://docs.aws.amazon.com/sagemaker/latest/dg/endpoint-scaling-loadtest.html). + +### Metrics + +The `IEndpointProductionVariant` interface also provides a set of APIs for referencing CloudWatch +metrics associated with a production variant associated with an endpoint: + +```typescript +import * as sagemaker from '@aws-cdk/aws-sagemaker'; + +const endpoint = new sagemaker.Endpoint(this, 'Endpoint', { endpointConfig }); +const productionVariant = endpoint.findProductionVariant('variantName'); +productionVariant.metricModelLatency().createAlarm(this, 'ModelLatencyAlarm', { + threshold: 100000, + evaluationPeriods: 3, +}); ``` diff --git a/packages/@aws-cdk/aws-sagemaker/lib/container-image.ts b/packages/@aws-cdk/aws-sagemaker/lib/container-image.ts new file mode 100644 index 0000000000000..bf311dbd65ccf --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/container-image.ts @@ -0,0 +1,77 @@ +import * as ecr from '@aws-cdk/aws-ecr'; +import * as assets from "@aws-cdk/aws-ecr-assets"; +import * as cdk from '@aws-cdk/core'; +import { Model } from './model'; + +/** + * The configuration for creating a container image. + */ +export interface ContainerImageConfig { + /** + * The image name. Images in Amazon ECR repositories can be specified by either using the full registry/repository:tag or + * registry/repository@digest. + * + * For example, 012345678910.dkr.ecr..amazonaws.com/:latest or + * 012345678910.dkr.ecr..amazonaws.com/@sha256:94afd1f2e64d908bc90dbca0035a5b567EXAMPLE. + */ + readonly imageName: string; +} + +/** + * Constructs for types of container images + */ +export abstract class ContainerImage { + /** + * Reference an image in an ECR repository + */ + public static fromEcrRepository(repository: ecr.IRepository, tag: string = 'latest'): ContainerImage { + return new EcrImage(repository, tag); + } + + /** + * Reference an image that's constructed directly from sources on disk + * + * @param scope The scope within which to create the image asset + * @param id The id to assign to the image asset + * @param props The properties of a Docker image asset + */ + public static fromAsset(scope: cdk.Construct, id: string, props: assets.DockerImageAssetProps): ContainerImage { + return new AssetImage(scope, id, props); + } + + /** + * Called when the image is used by a Model + */ + public abstract bind(scope: cdk.Construct, model: Model): ContainerImageConfig; +} + +class EcrImage extends ContainerImage { + constructor(private readonly repository: ecr.IRepository, private readonly tag: string) { + super(); + } + + public bind(_scope: cdk.Construct, model: Model): ContainerImageConfig { + this.repository.grantPull(model); + + return { + imageName: this.repository.repositoryUriForTag(this.tag) + }; + } +} + +class AssetImage extends ContainerImage { + private readonly asset: assets.DockerImageAsset; + + constructor(readonly scope: cdk.Construct, readonly id: string, readonly props: assets.DockerImageAssetProps) { + super(); + this.asset = new assets.DockerImageAsset(scope, id, props); + } + + public bind(_scope: cdk.Construct, model: Model): ContainerImageConfig { + this.asset.repository.grantPull(model); + + return { + imageName: this.asset.imageUri, + }; + } +} diff --git a/packages/@aws-cdk/aws-sagemaker/lib/endpoint-config.ts b/packages/@aws-cdk/aws-sagemaker/lib/endpoint-config.ts new file mode 100644 index 0000000000000..3c635905716b3 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/endpoint-config.ts @@ -0,0 +1,306 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as kms from '@aws-cdk/aws-kms'; +import * as cdk from '@aws-cdk/core'; +import { EOL } from 'os'; +import { CfnEndpointConfig } from '.'; +import { IModel } from './model'; + +/** + * The interface for a SageMaker EndpointConfig resource. + */ +export interface IEndpointConfig extends cdk.IResource { + /** + * The ARN of the endpoint configuration. + * + * @attribute + */ + readonly endpointConfigArn: string; + /** + * The name of the endpoint configuration. + * + * @attribute + */ + readonly endpointConfigName: string; +} + +/** + * Construction properties for a production variant. + */ +export interface ProductionVariantProps { + /** + * The size of the Elastic Inference (EI) instance to use for the production variant. EI instances + * provide on-demand GPU computing for inference. + * + * @default none + */ + readonly acceleratorType?: AcceleratorType; + /** + * Number of instances to launch initially. + * + * @default 1 + */ + readonly initialInstanceCount?: number; + /** + * Determines initial traffic distribution among all of the models that you specify in the + * endpoint configuration. The traffic to a production variant is determined by the ratio of the + * variant weight to the sum of all variant weight values across all production variants. + * + * @default 1.0 + */ + readonly initialVariantWeight?: number; + /** + * Instance type of the production variant. + * + * @default ml.t2.medium instance type. + */ + readonly instanceType?: ec2.InstanceType; + /** + * The model to host. + */ + readonly model: IModel; + /** + * Name of the production variant. + */ + readonly variantName: string; +} + +/** + * Represents a production variant that has been associated with an EndpointConfig. + */ +export interface ProductionVariant { + /** + * The size of the Elastic Inference (EI) instance to use for the production variant. EI instances + * provide on-demand GPU computing for inference. + * + * @default none + */ + readonly acceleratorType?: AcceleratorType; + /** + * Number of instances to launch initially. + */ + readonly initialInstanceCount: number; + /** + * Determines initial traffic distribution among all of the models that you specify in the + * endpoint configuration. The traffic to a production variant is determined by the ratio of the + * variant weight to the sum of all variant weight values across all production variants. + */ + readonly initialVariantWeight: number; + /** + * Instance type of the production variant. + */ + readonly instanceType: ec2.InstanceType; + /** + * The name of the model to host. + */ + readonly modelName: string; + /** + * The name of the production variant. + */ + readonly variantName: string; +} + +/** + * Name tag constant + */ +const NAME_TAG: string = 'Name'; + +/** + * Construction properties for a SageMaker EndpointConfig. + */ +export interface EndpointConfigProps { + /** + * Name of the endpoint configuration. + * + * @default AWS CloudFormation generates a unique physical ID and uses that ID for the endpoint + * configuration's name. + */ + readonly endpointConfigName?: string; + + /** + * Optional KMS encryption key associated with this stream. + * + * @default none + */ + readonly encryptionKey?: kms.IKey; + + /** + * A ProductionVariantProps object. + */ + readonly productionVariant: ProductionVariantProps; + + /** + * An optional list of extra ProductionVariantProps objects. + * + * @default none + */ + readonly extraProductionVariants?: ProductionVariantProps[]; +} + +/** + * The size of the Elastic Inference (EI) instance to use for the production variant. EI instances + * provide on-demand GPU computing for inference. + */ +export enum AcceleratorType { + /** + * Medium accelerator type. + */ + MEDIUM = 'ml.eia1.medium', + /** + * Large accelerator type. + */ + LARGE = 'ml.eia1.large ', + /** + * Extra large accelerator type. + */ + XLARGE = 'ml.eia1.xlarge', +} + +/** + * Defines a SageMaker EndpointConfig. + */ +export class EndpointConfig extends cdk.Resource implements IEndpointConfig { + /** + * Imports an EndpointConfig defined either outside the CDK or in a different CDK stack. + * @param scope the Construct scope. + * @param id the resource id. + * @param endpointConfigName the name of the endpoint configuration. + */ + public static fromEndpointConfigName(scope: cdk.Construct, id: string, endpointConfigName: string): IEndpointConfig { + class Import extends cdk.Resource implements IEndpointConfig { + public endpointConfigName = endpointConfigName; + public endpointConfigArn = cdk.Stack.of(this).formatArn({ + service: 'sagemaker', + resource: 'endpoint-config', + resourceName: this.endpointConfigName + }); + } + + return new Import(scope, id); + } + + /** + * The ARN of the endpoint configuration. + */ + public readonly endpointConfigArn: string; + /** + * The name of the endpoint configuration. + */ + public readonly endpointConfigName: string; + + private readonly _productionVariants: { [key: string]: ProductionVariant; } = {}; + + constructor(scope: cdk.Construct, id: string, props: EndpointConfigProps) { + super(scope, id, { + physicalName: props.endpointConfigName + }); + + // apply a name tag to the endpoint config resource + this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); + + [props.productionVariant, ...props.extraProductionVariants || []].map(p => this.addProductionVariant(p)); + + // create the endpoint configuration resource + const endpointConfig = new CfnEndpointConfig(this, 'EndpointConfig', { + kmsKeyId: (props.encryptionKey) ? props.encryptionKey.keyArn : undefined, + endpointConfigName: this.physicalName, + productionVariants: cdk.Lazy.anyValue({ produce: () => this.renderProductionVariants() }) + }); + this.endpointConfigName = this.getResourceNameAttribute(endpointConfig.attrEndpointConfigName); + this.endpointConfigArn = this.getResourceArnAttribute(endpointConfig.ref, { + service: 'sagemaker', + resource: 'endpoint-config', + resourceName: this.physicalName, + }); + } + + /** + * Add production variant to the endpoint configuration. + * + * @param props The properties of a production variant to add. + */ + public addProductionVariant(props: ProductionVariantProps): void { + if (props.variantName in this._productionVariants) { + throw new Error(`There is already a Production Variant with name '${props.variantName}'`); + } + this.validateProps(props); + this._productionVariants[props.variantName] = { + acceleratorType: props.acceleratorType, + initialInstanceCount: props.initialInstanceCount || 1, + initialVariantWeight: props.initialVariantWeight || 1.0, + instanceType: props.instanceType || ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MEDIUM), + modelName: props.model.modelName, + variantName: props.variantName + }; + } + + /** + * Get production variants associated with endpoint configuration. + */ + public get productionVariants(): ProductionVariant[] { + return Object.values(this._productionVariants); + } + + /** + * Find production variant based on variant name + * @param name Variant name from production variant + */ + public findProductionVariant(name: string): ProductionVariant { + const ret = this._productionVariants[name]; + if (!ret) { + throw new Error(`No variant with name: '${name}'`); + } + return ret; + } + + protected validate(): string[] { + const result = super.validate(); + // check we have 10 or fewer production variants + if (this.productionVariants.length > 10) { + result.push('Can\'t have more than 10 Production Variants'); + } + + return result; + } + + private validateProps(props: ProductionVariantProps): void { + const errors: string[] = []; + // check instance count is greater than zero + if (props.initialInstanceCount !== undefined && props.initialInstanceCount < 1) { + errors.push('Must have at least one instance'); + } + + // check variant weight is not negative + if (props.initialVariantWeight && props.initialVariantWeight < 0) { + errors.push('Cannot have negative variant weight'); + } + + // validate the instance type + if (props.instanceType) { + // check if a valid SageMaker instance type + const instanceType = props.instanceType.toString(); + if (!['c4', 'c5', 'c5d', 'g4dn', 'inf1', 'm4', 'm5', 'm5d', 'p2', 'p3', 'r5', 'r5d', 't2'] + .some(instanceClass => instanceType.indexOf(instanceClass) >= 0)) { + errors.push(`Invalid instance type for a SageMaker Endpoint Production Variant: ${instanceType}`); + } + } + + if (errors.length > 0) { + throw new Error(`Invalid Production Variant Props: ${errors.join(EOL)}`); + } + } + + /** + * Render the list of production variants. + */ + private renderProductionVariants(): CfnEndpointConfig.ProductionVariantProperty[] { + return this.productionVariants.map( v => ({ + acceleratorType: v.acceleratorType, + initialInstanceCount: v.initialInstanceCount, + initialVariantWeight: v.initialVariantWeight, + instanceType: 'ml.' + v.instanceType.toString(), + modelName: v.modelName, + variantName: v.variantName, + }) ); + } + +} diff --git a/packages/@aws-cdk/aws-sagemaker/lib/endpoint.ts b/packages/@aws-cdk/aws-sagemaker/lib/endpoint.ts new file mode 100644 index 0000000000000..138c90818797d --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/endpoint.ts @@ -0,0 +1,407 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as ec2 from "@aws-cdk/aws-ec2"; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { EOL } from 'os'; +import { CfnEndpoint } from '.'; +import { EndpointConfig, ProductionVariant } from "./endpoint-config"; +import { ScalableInstanceCount } from './scalable-instance-count'; + +/* + * Amazon SageMaker automatic scaling doesn't support automatic scaling for burstable instances such + * as T2, because they already allow for increased capacity under increased workloads. + * https://docs.aws.amazon.com/sagemaker/latest/dg/endpoint-auto-scaling-add-policy.html + */ +const BURSTABLE_INSTANCE_TYPE_PREFIXES = Object.entries(ec2.InstanceClass) + .filter(([name, _]) => name.startsWith('BURSTABLE')) + .map(([_, prefix]) => `${prefix}.`); + +/** + * The interface for a SageMaker Endpoint resource. + */ +export interface IEndpoint extends cdk.IResource { + /** + * The ARN of the endpoint. + * + * @attribute + */ + readonly endpointArn: string; + /** + * The name of the endpoint. + * + * @attribute + */ + readonly endpointName: string; + + /** + * Permits an IAM principal to invoke this endpoint + * @param grantee The principal to grant access to + */ + grantInvoke(grantee: iam.IGrantable): iam.Grant; +} + +/** + * Represents a production variant that has been associated with an endpoint. + */ +export interface IEndpointProductionVariant { + /** + * The name of the production variant. + */ + readonly variantName: string; + /** + * Return the given named metric for Endpoint + * + * @default sum over 5 minutes + */ + metric(namespace: string, metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for the number of invocations + * + * @default sum over 5 minutes + */ + metricInvocations(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for the number of invocations per instance + * + * @default sum over 5 minutes + */ + metricInvocationsPerInstance(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for model latency + * + * @default average over 5 minutes + */ + metricModelLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for overhead latency + * + * @default average over 5 minutes + */ + metricOverheadLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for the number of invocations by HTTP response code + * + * @default sum over 5 minutes + */ + metricInvocationResponseCode(responseCode: InvocationHttpResponseCode, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for disk utilization + * + * @default average over 5 minutes + */ + metricDiskUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for CPU utilization + * + * @default average over 5 minutes + */ + metricCPUUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for memory utilization + * + * @default average over 5 minutes + */ + metricMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for GPU utilization + * + * @default average over 5 minutes + */ + metricGPUUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Metric for GPU memory utilization + * + * @default average over 5 minutes + */ + metricGPUMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Enable autoscaling for SageMaker Endpoint production variant + * + * @param scalingProps EnableScalingProps + */ + autoScaleInstanceCount(scalingProps: appscaling.EnableScalingProps): ScalableInstanceCount; +} + +class EndpointProductionVariant implements IEndpointProductionVariant { + public readonly variantName: string; + private readonly endpoint: Endpoint; + private readonly initialInstanceCount: number; + private readonly instanceType: ec2.InstanceType; + private scalableInstanceCount?: ScalableInstanceCount; + + constructor(endpoint: Endpoint, variant: ProductionVariant) { + this.initialInstanceCount = variant.initialInstanceCount; + this.instanceType = variant.instanceType; + this.variantName = variant.variantName; + this.endpoint = endpoint; + } + + public metric( + namespace: string, + metricName: string, + props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace, + metricName, + dimensions: { + EndpointName: this.endpoint.endpointName, + VariantName: this.variantName + }, + statistic: 'Sum', + ...props + }); + } + + public metricInvocations(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('AWS/SageMaker', 'Invocations', props); + } + + public metricInvocationsPerInstance(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('AWS/SageMaker', 'InvocationsPerInstance', props); + } + + public metricModelLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('AWS/SageMaker', 'ModelLatency', { + statistic: 'Average', + ...props + }); + } + + public metricOverheadLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('AWS/SageMaker', 'OverheadLatency', { + statistic: 'Average', + ...props + }); + } + + public metricInvocationResponseCode( + responseCode: InvocationHttpResponseCode, + props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('AWS/SageMaker', responseCode, props); + } + + public metricDiskUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('/aws/sagemaker/Endpoints', 'DiskUtilization', { + statistic: 'Average', + ...props + }); + } + + public metricCPUUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('/aws/sagemaker/Endpoints', 'CPUUtilization', { + statistic: 'Average', + ...props + }); + } + + public metricMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('/aws/sagemaker/Endpoints', 'MemoryUtilization', { + statistic: 'Average', + ...props + }); + } + + public metricGPUUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('/aws/sagemaker/Endpoints', 'GPUUtilization', { + statistic: 'Average', + ...props + }); + } + + public metricGPUMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('/aws/sagemaker/Endpoints', 'GPUMemoryUtilization', { + statistic: 'Average', + ...props + }); + } + + public autoScaleInstanceCount(scalingProps: appscaling.EnableScalingProps): ScalableInstanceCount { + const errors: string[] = []; + if (scalingProps.minCapacity && scalingProps.minCapacity > this.initialInstanceCount) { + errors.push(`minCapacity cannot be greater than initial instance count: ${this.initialInstanceCount}`); + } + if (scalingProps.maxCapacity && scalingProps.maxCapacity < this.initialInstanceCount) { + errors.push(`maxCapacity cannot be less than initial instance count: ${this.initialInstanceCount}`); + } + if (BURSTABLE_INSTANCE_TYPE_PREFIXES.some(prefix => this.instanceType.toString().startsWith(prefix))) { + errors.push(`AutoScaling not supported for burstable instance types like ${this.instanceType}`); + } + if (this.scalableInstanceCount) { + errors.push('AutoScaling of task count already enabled for this service'); + } + + if (errors.length > 0) { + throw new Error(`Invalid Application Auto Scaling configuration: ${errors.join(EOL)}`); + } + + return this.scalableInstanceCount = new ScalableInstanceCount(this.endpoint, 'InstanceCount', { + serviceNamespace: appscaling.ServiceNamespace.SAGEMAKER, + resourceId: `endpoint/${this.endpoint.endpointName}/variant/${this.variantName}`, + dimension: 'sagemaker:variant:DesiredInstanceCount', + role: this.makeScalingRole(), + minCapacity: scalingProps.minCapacity || this.initialInstanceCount, + maxCapacity: scalingProps.maxCapacity || this.initialInstanceCount, + }); + } + + /** + * Return the service linked role that will be used for AutoScaling + * + * Documentation is available here: https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-service-linked-roles.html + */ + private makeScalingRole(): iam.IRole { + // Use a Service Linked Role. + return iam.Role.fromRoleArn(this.endpoint, 'ScalingRole', cdk.Stack.of(this.endpoint).formatArn({ + service: 'iam', + region: '', + resource: 'role/aws-service-role/sagemaker.application-autoscaling.amazonaws.com', + resourceName: 'AWSServiceRoleForApplicationAutoScaling_SageMakerEndpoint' + })); + } +} +/** + * Name tag constant + */ +const NAME_TAG: string = 'Name'; + +abstract class EndpointBase extends cdk.Resource implements IEndpoint { + /** + * The ARN of the endpoint. + * + * @attribute + */ + public abstract readonly endpointArn: string; + + /** + * The name of the endpoint. + * + * @attribute + */ + public abstract readonly endpointName: string; + + /** + * Permits an IAM principal to invoke this endpoint + * @param grantee The principal to grant access to + */ + public grantInvoke(grantee: iam.IGrantable) { + return iam.Grant.addToPrincipal({ + grantee, + actions: ['sagemaker:InvokeEndpoint'], + resourceArns: [this.endpointArn], + }); + } +} + +/** + * Construction properties for a SageMaker Endpoint. + */ +export interface EndpointProps { + + /** + * Name of the endpoint. + * + * @default AWS CloudFormation generates a unique physical ID and uses that ID for the endpoint's + * name. + */ + readonly endpointName?: string; + + /** + * The endpoint configuration to use for this endpoint. + * + * [disable-awslint:ref-via-interface] + */ + readonly endpointConfig: EndpointConfig; +} + +/** + * HTTP response codes for Endpoint invocations + */ +export enum InvocationHttpResponseCode { + /** + * 4xx response codes from Endpoint invocations + */ + INVOCATION_4XX_ERRORS = 'Invocation4XXErrors', + + /** + * 5xx response codes from Endpoint invocations + */ + INVOCATION_5XX_ERRORS = 'Invocation5XXErrors', +} + +/** + * Defines a SageMaker endpoint. + */ +export class Endpoint extends EndpointBase { + /** + * Imports an Endpoint defined either outside the CDK or in a different CDK stack. + * @param scope the Construct scope. + * @param id the resource id. + * @param endpointName the name of the endpoint. + */ + public static fromEndpointName(scope: cdk.Construct, id: string, endpointName: string): IEndpoint { + class Import extends EndpointBase { + public endpointName = endpointName; + public endpointArn = cdk.Stack.of(this).formatArn({ + service: 'sagemaker', + resource: 'endpoint', + resourceName: this.endpointName + }); + } + + return new Import(scope, id); + } + + /** + * The ARN of the endpoint. + * + * @attribute + */ + public readonly endpointArn: string; + /** + * The name of the endpoint. + * + * @attribute + */ + public readonly endpointName: string; + private readonly endpointConfig: EndpointConfig; + + constructor(scope: cdk.Construct, id: string, props: EndpointProps) { + super(scope, id, { + physicalName: props.endpointName + }); + + // apply a name tag to the endpoint resource + this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); + + this.endpointConfig = props.endpointConfig; + + // create the endpoint resource + const endpoint = new CfnEndpoint(this, 'Endpoint', { + endpointConfigName: props.endpointConfig.endpointConfigName, + endpointName: this.physicalName, + }); + this.endpointName = this.getResourceNameAttribute(endpoint.attrEndpointName); + this.endpointArn = this.getResourceArnAttribute(endpoint.ref, { + service: 'sagemaker', + resource: 'endpoint', + resourceName: this.physicalName, + }); + } + + /** + * Get production variants associated with endpoint. + */ + public get productionVariants(): IEndpointProductionVariant[] { + return this.endpointConfig.productionVariants.map(v => new EndpointProductionVariant(this, v)); + } + + /** + * Find production variant based on variant name + * @param name Variant name from production variant + */ + public findProductionVariant(name: string): IEndpointProductionVariant { + const variant = this.endpointConfig.findProductionVariant(name); + return new EndpointProductionVariant(this, variant); + } +} diff --git a/packages/@aws-cdk/aws-sagemaker/lib/index.ts b/packages/@aws-cdk/aws-sagemaker/lib/index.ts index 4c40a31057568..701afb3abb791 100644 --- a/packages/@aws-cdk/aws-sagemaker/lib/index.ts +++ b/packages/@aws-cdk/aws-sagemaker/lib/index.ts @@ -1,2 +1,9 @@ +export * from './container-image'; +export * from './endpoint'; +export * from './endpoint-config'; +export * from './model'; +export * from './model-data'; +export * from './scalable-instance-count'; + // AWS::SageMaker CloudFormation Resources: export * from './sagemaker.generated'; diff --git a/packages/@aws-cdk/aws-sagemaker/lib/model-data.ts b/packages/@aws-cdk/aws-sagemaker/lib/model-data.ts new file mode 100644 index 0000000000000..066b83c8e1cf6 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/model-data.ts @@ -0,0 +1,87 @@ +import * as s3 from '@aws-cdk/aws-s3'; +import * as assets from '@aws-cdk/aws-s3-assets'; +import * as cdk from '@aws-cdk/core'; +import { IModel } from './model'; + +// The only supported extension for local asset model data +const ARTIFACT_EXTENSION = ".tar.gz"; + +/** + * The configuration needed to reference model artifacts. + */ +export interface ModelDataConfig { + /** + * The S3 path where the model artifacts, which result from model training, are stored. This path + * must point to a single gzip compressed tar archive (.tar.gz suffix). + */ + readonly uri: string; +} + +/** + * Model data represents the source of model artifacts, which will ultimately be loaded from an S3 + * location. + */ +export abstract class ModelData { + /** + * Constructs model data which is already available within S3. + * @param bucket The S3 bucket within which the model artifacts are stored + * @param objectKey The S3 object key at which the model artifacts are stored + */ + public static fromBucket(bucket: s3.IBucket, objectKey: string): ModelData { + return new S3ModelData(bucket, objectKey); + } + + /** + * Constructs model data that will be uploaded to S3 as part of the CDK app deployment. + * @param scope The scope within which to create a new asset + * @param id The id to associate with the new asset + * @param path The local path to a model artifact file as a gzipped tar file + */ + public static fromAsset(scope: cdk.Construct, id: string, path: string): ModelData { + return new AssetModelData(scope, id, path); + } + + /** + * This method is invoked by the SageMaker Model construct when it needs to resolve the model + * data to a URI. + * @param scope The scope within which the model data is resolved + * @param model The Model construct performing the URI resolution + */ + public abstract bind(scope: cdk.Construct, model: IModel): ModelDataConfig; +} + +class S3ModelData extends ModelData { + constructor(private readonly bucket: s3.IBucket, private readonly objectKey: string) { + super(); + } + + public bind(_scope: cdk.Construct, model: IModel): ModelDataConfig { + this.bucket.grantRead(model); + + return { + uri: this.bucket.urlForObject(this.objectKey), + }; + } +} + +class AssetModelData extends ModelData { + private readonly asset: assets.Asset; + + constructor(readonly scope: cdk.Construct, readonly id: string, readonly path: string) { + super(); + this.asset = new assets.Asset(scope, id, { + path: this.path, + }); + if (this.asset.isZipArchive || !path.toLowerCase().endsWith(ARTIFACT_EXTENSION)) { + throw new Error(`Asset must be a ${ARTIFACT_EXTENSION} file (${this.path})`); + } + } + + public bind(_scope: cdk.Construct, model: IModel): ModelDataConfig { + this.asset.grantRead(model); + + return { + uri: this.asset.s3Url, + }; + } +} diff --git a/packages/@aws-cdk/aws-sagemaker/lib/model.ts b/packages/@aws-cdk/aws-sagemaker/lib/model.ts new file mode 100644 index 0000000000000..26bb1c5e4112e --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/model.ts @@ -0,0 +1,381 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { CfnModel } from '.'; +import { ContainerImage } from './container-image'; +import { ModelData } from './model-data'; + +/** + * Name tag constant + */ +const NAME_TAG: string = 'Name'; + +/** + * Interface that defines a Model resource. + */ +export interface IModel extends cdk.IResource, iam.IGrantable, ec2.IConnectable { + /** + * Returns the ARN of this model. + * + * @attribute + */ + readonly modelArn: string; + + /** + * Returns the name of this model. + * + * @attribute + */ + readonly modelName: string; + + /** + * The IAM role associated with this Model. + */ + readonly role?: iam.IRole; + + /** + * Adds a statement to the IAM role assumed by the instance. + */ + addToRolePolicy(statement: iam.PolicyStatement): void; +} + +/** + * Represents a Model resource defined outside this stack. + */ +export interface ModelAttributes { + /** + * The name of this model. + */ + readonly modelName: string; + + /** + * The IAM execution role associated with this model. + * + * @default When not provided, any role-related operations will no-op. + */ + readonly role?: iam.IRole; + + /** + * The security groups for this model, if in a VPC. + * + * @default When not provided, the connections to/from this model cannot be managed. + */ + readonly securityGroups?: ec2.ISecurityGroup[]; +} + +abstract class ModelBase extends cdk.Resource implements IModel { + /** + * Returns the ARN of this model. + * @attribute + */ + public abstract readonly modelArn: string; + /** + * Returns the name of the model. + * @attribute + */ + public abstract readonly modelName: string; + /** + * Execution role for SageMaker Model + */ + public abstract readonly role?: iam.IRole; + /** + * The principal this Model is running as + */ + public abstract readonly grantPrincipal: iam.IPrincipal; + /** + * An accessor for the Connections object that will fail if this Model does not have a VPC + * configured. + */ + public get connections(): ec2.Connections { + if (!this._connections) { + throw new Error('Cannot manage network access without configuring a VPC'); + } + return this._connections; + } + /** + * The actual Connections object for this Model. This may be unset in the event that a VPC has not + * been configured. + * @internal + */ + protected _connections: ec2.Connections | undefined; + + /** + * Adds a statement to the IAM role assumed by the instance. + */ + public addToRolePolicy(statement: iam.PolicyStatement) { + if (!this.role) { + return; + } + + this.role.addToPolicy(statement); + } +} + +/** + * Describes the container, as part of model definition. + */ +export interface ContainerDefinition { + /** + * The image used to start a container. + */ + readonly image: ContainerImage; + + /** + * A map of environment variables to pass into the container. + * + * @default none + */ + readonly environment?: {[key: string]: string}; + + /** + * Hostname of the container. + * + * @default none + */ + readonly containerHostname?: string; + + /** + * S3 path to the model artifacts. + * + * @default none + */ + readonly modelData?: ModelData; +} + +/** + * Construction properties for a SageMaker Model. + */ +export interface ModelProps { + + /** + * The IAM role that the Amazon SageMaker service assumes. + * + * @default a new IAM role will be created. + */ + readonly role?: iam.IRole; + + /** + * Name of the SageMaker Model. + * + * @default AWS CloudFormation generates a unique physical ID and uses that ID for the model's + * name. + */ + readonly modelName?: string; + + /** + * The VPC to deploy the endpoint to. + * + * @default none + */ + readonly vpc?: ec2.IVpc; + + /** + * The VPC subnets to deploy the endpoints. + * + * @default none + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * The security groups to associate to the Model. If no security groups are provided and 'vpc' is + * configured, one security group will be created automatically. + * + * @default A security group will be automatically created if 'vpc' is supplied + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Specifies the primary container or the first container in an inference pipeline. Additional + * containers for an inference pipeline can be provided using the "extraContainers" property. + * + */ + readonly container: ContainerDefinition; + + /** + * Specifies additional containers for an inference pipeline. + * + * @default none + */ + readonly extraContainers?: ContainerDefinition[]; + + /** + * Whether to allow the SageMaker Model to send all network traffic + * + * If set to false, you must individually add traffic rules to allow the + * SageMaker Model to connect to network targets. + * + * Only used if 'vpc' is supplied. + * + * @default true + */ + readonly allowAllOutbound?: boolean; +} + +/** + * Defines a SageMaker Model. + */ +export class Model extends ModelBase { + /** + * Imports a Model defined either outside the CDK or in a different CDK stack. + * @param scope the Construct scope. + * @param id the resource id. + * @param modelName the name of the model. + */ + public static fromModelName(scope: cdk.Construct, id: string, modelName: string): IModel { + return Model.fromModelAttributes(scope, id, {modelName}); + } + + /** + * Imports a Model defined either outside the CDK or in a different CDK stack. + * @param scope the Construct scope. + * @param id the resource id. + * @param attrs the attributes of the model to import. + */ + public static fromModelAttributes(scope: cdk.Construct, id: string, attrs: ModelAttributes): IModel { + const modelName = attrs.modelName; + const role = attrs.role; + + class Import extends ModelBase { + public readonly modelName = modelName; + public readonly role = role; + public readonly grantPrincipal: iam.IPrincipal; + public readonly modelArn: string; + + constructor(s: cdk.Construct, i: string) { + super(s, i); + + this.modelArn = cdk.Stack.of(this).formatArn({ + service: 'sagemaker', + resource: 'model', + resourceName: this.modelName + }); + this.grantPrincipal = role || new iam.UnknownPrincipal({ resource: this }); + if (attrs.securityGroups) { + this._connections = new ec2.Connections({ + securityGroups: attrs.securityGroups + }); + } + } + } + + return new Import(scope, id); + } + + /** + * Returns the ARN of this model. + * @attribute + */ + public readonly modelArn: string; + /** + * Returns the name of the model. + * @attribute + */ + public readonly modelName: string; + /** + * Execution role for SageMaker Model + */ + public readonly role?: iam.IRole; + /** + * The principal this Model is running as + */ + public readonly grantPrincipal: iam.IPrincipal; + private readonly subnets: ec2.SelectedSubnets | undefined; + + constructor(scope: cdk.Construct, id: string, props: ModelProps) { + super(scope, id, { + physicalName: props.modelName + }); + + // validate containers + const containers = [props.container, ...props.extraContainers || []]; + if (containers.length > 5) { + throw new RangeError('Cannot have more than 5 containers in inference pipeline'); + } + + this._connections = this.configureNetworking(props); + this.subnets = (props.vpc) ? props.vpc.selectSubnets(props.vpcSubnets) : undefined; + + // set the sagemaker role or create new one + this.role = props.role || this.createSageMakerRole(); + this.grantPrincipal = this.role; + + this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); + + const model = new CfnModel(this, 'Model', { + executionRoleArn: this.role.roleArn, + modelName: this.physicalName, + primaryContainer: (containers.length === 1) ? + this.renderContainer(containers[0]) : undefined, + vpcConfig: cdk.Lazy.anyValue({ produce: () => this.renderVpcConfig() }), + containers: (containers.length === 1) ? + undefined : containers.map(c => this.renderContainer(c)), + }); + this.modelName = this.getResourceNameAttribute(model.attrModelName); + this.modelArn = this.getResourceArnAttribute(model.ref, { + service: 'sagemaker', + resource: 'model', + resourceName: this.physicalName, + }); + + /* + * SageMaker model creation will fail if the model's execution role does not have read access to + * its model data in S3. Since the CDK uses a separate AWS::IAM::Policy CloudFormation resource + * to attach inline policies to IAM roles, the following line ensures that the role and its + * AWS::IAM::Policy resource are deployed prior to model creation. + */ + model.node.addDependency(this.role); + } + + private renderContainer(container: ContainerDefinition): CfnModel.ContainerDefinitionProperty { + return { + image: container.image.bind(this, this).imageName, + containerHostname: container.containerHostname, + environment: container.environment, + modelDataUrl: container.modelData ? container.modelData.bind(this, this).uri : undefined, + }; + } + + private configureNetworking(props: ModelProps): ec2.Connections | undefined { + if ((props.securityGroups || props.allowAllOutbound !== undefined) && !props.vpc) { + throw new Error(`Cannot configure 'securityGroups' or 'allowAllOutbound' without configuring a VPC`); + } + + if (!props.vpc) { return undefined; } + + if ((props.securityGroups && props.securityGroups.length > 0) && props.allowAllOutbound !== undefined) { + throw new Error(`Configure 'allowAllOutbound' directly on the supplied SecurityGroups`); + } + + let securityGroups: ec2.ISecurityGroup[]; + if (props.securityGroups && props.securityGroups.length > 0) { + securityGroups = props.securityGroups; + } else { + const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', { + vpc: props.vpc, + allowAllOutbound: props.allowAllOutbound, + }); + securityGroups = [securityGroup]; + } + + return new ec2.Connections({ securityGroups }); + } + + private renderVpcConfig(): CfnModel.VpcConfigProperty | undefined { + if (!this._connections) { return undefined; } + + return { + subnets: this.subnets!.subnetIds, + securityGroupIds: this.connections.securityGroups.map(s => s.securityGroupId) + }; + } + + private createSageMakerRole(): iam.IRole { + const sagemakerRole = new iam.Role(this, 'Role', { + assumedBy: new iam.ServicePrincipal('sagemaker.amazonaws.com'), + }); + // Grant SageMaker FullAccess + sagemakerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSageMakerFullAccess')); + + return sagemakerRole; + } +} diff --git a/packages/@aws-cdk/aws-sagemaker/lib/scalable-instance-count.ts b/packages/@aws-cdk/aws-sagemaker/lib/scalable-instance-count.ts new file mode 100644 index 0000000000000..f7917bade8c3f --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/lib/scalable-instance-count.ts @@ -0,0 +1,66 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; +import * as cdk from '@aws-cdk/core'; + +/** + * The properties of a scalable attribute representing task count. + */ +export interface ScalableInstanceCountProps extends appscaling.BaseScalableAttributeProps { +} + +/** + * A scalable sagemaker endpoint attribute + */ +export class ScalableInstanceCount extends appscaling.BaseScalableAttribute { + /** + * Constructs a new instance of the ScalableInstanceCount class. + */ + constructor(scope: cdk.Construct, id: string, props: ScalableInstanceCountProps) { + super(scope, id, props); + } + + /** + * Scales in or out to achieve a target requests per second per instance. + */ + public scaleOnInvocations(id: string, props: InvocationsScalingProps) { + const predefinedMetric = appscaling.PredefinedMetric.SAGEMAKER_VARIANT_INVOCATIONS_PER_INSTANCE; + + super.doScaleToTrackMetric(id, { + policyName: props.policyName, + disableScaleIn: props.disableScaleIn, + scaleInCooldown: props.scaleInCooldown, + scaleOutCooldown: props.scaleOutCooldown, + targetValue: this.calculateScalingTarget(props), + predefinedMetric, + }); + } + + /** + * Calculate target value based on a ScalableProductionVariant + * + * Documentation for the equation is here: https://docs.aws.amazon.com/sagemaker/latest/dg/endpoint-scaling-loadtest.html + * @param scalableProductionVariant ScalableProductionVariant instance + */ + private calculateScalingTarget( props: InvocationsScalingProps): number { + const safetyFactor = props.safetyFactor || 0.5; + return safetyFactor * props.maxRequestsPerSecond * 60; + } +} + +/** + * Properties for enabling SageMaker Endpoint utilization tracking + */ +export interface InvocationsScalingProps extends appscaling.BaseTargetTrackingProps { + /** + * Max RPS per instance used for calculating the target SageMaker variant invocation per instance + * + * More documentation available here: https://docs.aws.amazon.com/sagemaker/latest/dg/endpoint-scaling-loadtest.html + */ + readonly maxRequestsPerSecond: number; + /** + * Safty factor for calculating the target SageMaker variant invocation per instance + * + * More documentation available here: https://docs.aws.amazon.com/sagemaker/latest/dg/endpoint-scaling-loadtest.html + * @default 0.5 + */ + readonly safetyFactor?: number; +} diff --git a/packages/@aws-cdk/aws-sagemaker/package.json b/packages/@aws-cdk/aws-sagemaker/package.json index e47d7dc1a176c..70e85a2eaf251 100644 --- a/packages/@aws-cdk/aws-sagemaker/package.json +++ b/packages/@aws-cdk/aws-sagemaker/package.json @@ -61,35 +61,40 @@ "url": "https://aws.amazon.com", "organization": true }, - "jest": { - "moduleFileExtensions": [ - "js" - ], - "coverageThreshold": { - "global": { - "branches": 60, - "statements": 80 - } - }, - "collectCoverage": true, - "coverageReporters": [ - "lcov", - "html", - "text-summary" - ] - }, "license": "Apache-2.0", "devDependencies": { "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", + "nodeunit": "^0.11.3", "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/assets": "0.0.0", + "@aws-cdk/aws-applicationautoscaling": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-ecr-assets": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0" }, "peerDependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/assets": "0.0.0", + "@aws-cdk/aws-applicationautoscaling": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr-assets": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0" }, "engines": { "node": ">= 10.3.0" diff --git a/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.expected.json b/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.expected.json new file mode 100644 index 0000000000000..80e50e378504a --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.expected.json @@ -0,0 +1,1365 @@ +{ + "Parameters": { + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51": { + "Type": "String", + "Description": "S3 bucket for asset \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + }, + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF": { + "Type": "String", + "Description": "S3 key for asset version \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + }, + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916ArtifactHashB0F74179": { + "Type": "String", + "Description": "Artifact hash for asset \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + } + }, + "Resources": { + "VPCB9E5F0B4": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC" + } + ] + } + }, + "VPCPublicSubnet1SubnetB4246D30": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet1RouteTableFEE4B781": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableAssociation0B0896DC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + } + } + }, + "VPCPublicSubnet1DefaultRoute91CEF279": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet1EIP6AD938E8": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1NATGatewayE0556630": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet1EIP6AD938E8", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet2Subnet74179F39": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet2RouteTable6F1A15F1": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTableAssociation5A808732": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + } + } + }, + "VPCPublicSubnet2DefaultRouteB7481BBA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet2EIP4947BC00": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2NATGateway3C070193": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet2EIP4947BC00", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet3Subnet631C5E25": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet3RouteTable98AE0E14": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTableAssociation427FE0C6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + } + } + }, + "VPCPublicSubnet3DefaultRouteA0D29D46": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet3EIPAD4BC883": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3NATGatewayD3048F5C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet3EIPAD4BC883", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPrivateSubnet1Subnet8BCA10E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableBE8A6027": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableAssociation347902D1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + } + } + }, + "VPCPrivateSubnet1DefaultRouteAE1D6490": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet1NATGatewayE0556630" + } + } + }, + "VPCPrivateSubnet2SubnetCFCDAA7A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet2RouteTable0A19E10E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTableAssociation0C73D413": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + } + } + }, + "VPCPrivateSubnet2DefaultRouteF4F5CFD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet2NATGateway3C070193" + } + } + }, + "VPCPrivateSubnet3Subnet3EDCD457": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet3RouteTable192186F8": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTableAssociationC28D144E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + } + }, + "VPCPrivateSubnet3DefaultRoute27F311AE": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet3NATGatewayD3048F5C" + } + } + }, + "VPCIGWB7E252D3": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/VPC" + } + ] + } + }, + "VPCVPCGW99B986DC": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "InternetGatewayId": { + "Ref": "VPCIGWB7E252D3" + } + } + }, + "ModelWithArtifactAndVpcSecurityGroupB499C626": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-cdk-sagemaker-endpoint/ModelWithArtifactAndVpc/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/ModelWithArtifactAndVpc" + } + ], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "ModelWithArtifactAndVpcRole6BA49FD3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sagemaker.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonSageMakerFullAccess" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/ModelWithArtifactAndVpc" + } + ] + } + }, + "ModelWithArtifactAndVpcRoleDefaultPolicyC77E2AFA": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ModelWithArtifactAndVpcRoleDefaultPolicyC77E2AFA", + "Roles": [ + { + "Ref": "ModelWithArtifactAndVpcRole6BA49FD3" + } + ] + } + }, + "ModelWithArtifactAndVpcModel30604F15": { + "Type": "AWS::SageMaker::Model", + "Properties": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "ModelWithArtifactAndVpcRole6BA49FD3", + "Arn" + ] + }, + "PrimaryContainer": { + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:26aab3505e4deba4c58890a2836f781a4fd172b59fdd74732a76d59860df5cc9" + ] + ] + }, + "ModelDataUrl": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + } + ] + ] + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/ModelWithArtifactAndVpc" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "ModelWithArtifactAndVpcSecurityGroupB499C626", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + }, + { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + ] + } + }, + "DependsOn": [ + "ModelWithArtifactAndVpcRoleDefaultPolicyC77E2AFA", + "ModelWithArtifactAndVpcRole6BA49FD3" + ] + }, + "ModelWithoutArtifactAndVpcRole10D89F15": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sagemaker.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonSageMakerFullAccess" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/ModelWithoutArtifactAndVpc" + } + ] + } + }, + "ModelWithoutArtifactAndVpcRoleDefaultPolicy88BAF094": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ModelWithoutArtifactAndVpcRoleDefaultPolicy88BAF094", + "Roles": [ + { + "Ref": "ModelWithoutArtifactAndVpcRole10D89F15" + } + ] + } + }, + "ModelWithoutArtifactAndVpcModel9A8AD144": { + "Type": "AWS::SageMaker::Model", + "Properties": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "ModelWithoutArtifactAndVpcRole10D89F15", + "Arn" + ] + }, + "PrimaryContainer": { + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:26aab3505e4deba4c58890a2836f781a4fd172b59fdd74732a76d59860df5cc9" + ] + ] + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/ModelWithoutArtifactAndVpc" + } + ] + }, + "DependsOn": [ + "ModelWithoutArtifactAndVpcRoleDefaultPolicy88BAF094", + "ModelWithoutArtifactAndVpcRole10D89F15" + ] + }, + "EndpointConfigFD7B6F91": { + "Type": "AWS::SageMaker::EndpointConfig", + "Properties": { + "ProductionVariants": [ + { + "InitialInstanceCount": 1, + "InitialVariantWeight": 1, + "InstanceType": "ml.m5.large", + "ModelName": { + "Fn::GetAtt": [ + "ModelWithArtifactAndVpcModel30604F15", + "ModelName" + ] + }, + "VariantName": "firstVariant" + }, + { + "InitialInstanceCount": 1, + "InitialVariantWeight": 1, + "InstanceType": "ml.t2.medium", + "ModelName": { + "Fn::GetAtt": [ + "ModelWithArtifactAndVpcModel30604F15", + "ModelName" + ] + }, + "VariantName": "secondVariant" + }, + { + "InitialInstanceCount": 1, + "InitialVariantWeight": 2, + "InstanceType": "ml.t2.medium", + "ModelName": { + "Fn::GetAtt": [ + "ModelWithoutArtifactAndVpcModel9A8AD144", + "ModelName" + ] + }, + "VariantName": "thirdVariant" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/EndpointConfig" + } + ] + } + }, + "Endpoint8024A810": { + "Type": "AWS::SageMaker::Endpoint", + "Properties": { + "EndpointConfigName": { + "Fn::GetAtt": [ + "EndpointConfigFD7B6F91", + "EndpointConfigName" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-endpoint/Endpoint" + } + ] + } + }, + "EndpointInstanceCountTargetDA8C8EDB": { + "Type": "AWS::ApplicationAutoScaling::ScalableTarget", + "Properties": { + "MaxCapacity": 3, + "MinCapacity": 1, + "ResourceId": { + "Fn::Join": [ + "", + [ + "endpoint/", + { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + }, + "/variant/firstVariant" + ] + ] + }, + "RoleARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":role/aws-service-role/sagemaker.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_SageMakerEndpoint" + ] + ] + }, + "ScalableDimension": "sagemaker:variant:DesiredInstanceCount", + "ServiceNamespace": "sagemaker" + } + }, + "EndpointInstanceCountTargetLimitRPSE1D92DB6": { + "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", + "Properties": { + "PolicyName": "awscdksagemakerendpointEndpointInstanceCountTargetLimitRPSCC857664", + "PolicyType": "TargetTrackingScaling", + "ScalingTargetId": { + "Ref": "EndpointInstanceCountTargetDA8C8EDB" + }, + "TargetTrackingScalingPolicyConfiguration": { + "PredefinedMetricSpecification": { + "PredefinedMetricType": "SageMakerVariantInvocationsPerInstance" + }, + "TargetValue": 900 + } + } + }, + "Invoker060A9026": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "InvokerDefaultPolicy3FF8208D": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sagemaker:InvokeEndpoint", + "Effect": "Allow", + "Resource": { + "Ref": "Endpoint8024A810" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "InvokerDefaultPolicy3FF8208D", + "Roles": [ + { + "Ref": "Invoker060A9026" + } + ] + } + }, + "InvocationsAlarmBC3830BD": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 2, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "Invocations", + "Namespace": "AWS/SageMaker", + "Period": 300, + "Statistic": "Sum", + "Threshold": 1 + } + }, + "InvocationsPerInstanceAlarm82CBCF29": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 5, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "InvocationsPerInstance", + "Namespace": "AWS/SageMaker", + "Period": 300, + "Statistic": "Sum", + "Threshold": 4 + } + }, + "ModelLatencyAlarm96AC7D24": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 8, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "ModelLatency", + "Namespace": "AWS/SageMaker", + "Period": 300, + "Statistic": "Average", + "Threshold": 7 + } + }, + "OverheadLatencyAlarm10D8981B": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 11, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "OverheadLatency", + "Namespace": "AWS/SageMaker", + "Period": 300, + "Statistic": "Average", + "Threshold": 10 + } + }, + "Invocation5XXErrorsAlarmF9BAF026": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 14, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "Invocation5XXErrors", + "Namespace": "AWS/SageMaker", + "Period": 300, + "Statistic": "Sum", + "Threshold": 13 + } + }, + "DiskUtilizationAlarmE19E4184": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 17, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "DiskUtilization", + "Namespace": "/aws/sagemaker/Endpoints", + "Period": 300, + "Statistic": "Average", + "Threshold": 16 + } + }, + "CPUUtilizationAlarm4D91B4D0": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 20, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "CPUUtilization", + "Namespace": "/aws/sagemaker/Endpoints", + "Period": 300, + "Statistic": "Average", + "Threshold": 19 + } + }, + "MemoryUtilizationAlarm544270BF": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 23, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "MemoryUtilization", + "Namespace": "/aws/sagemaker/Endpoints", + "Period": 300, + "Statistic": "Average", + "Threshold": 22 + } + }, + "GPUUtilizationAlarmEC9BEC6F": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 26, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "GPUUtilization", + "Namespace": "/aws/sagemaker/Endpoints", + "Period": 300, + "Statistic": "Average", + "Threshold": 25 + } + }, + "GPUMemoryUtilizationAlarmD03813BC": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 29, + "Dimensions": [ + { + "Name": "EndpointName", + "Value": { + "Fn::GetAtt": [ + "Endpoint8024A810", + "EndpointName" + ] + } + }, + { + "Name": "VariantName", + "Value": "firstVariant" + } + ], + "MetricName": "GPUMemoryUtilization", + "Namespace": "/aws/sagemaker/Endpoints", + "Period": 300, + "Statistic": "Average", + "Threshold": 28 + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.ts b/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.ts new file mode 100644 index 0000000000000..7707396d6f6b1 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/integ.endpoint.ts @@ -0,0 +1,127 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as path from "path"; +import * as sagemaker from '../lib'; + +/* + * Stack verification steps: + * aws sagemaker-runtime invoke-endpoint --endpoint-name --body "any string" /tmp/inference.txt && cat /tmp/inference.txt + * + * The above command will result in one of the following outputs (based on relative variant weight). + * + * Roughly 25% of the time: + * + * { + * "ContentType": "text/plain", + * "InvokedProductionVariant": "firstVariant" + * } + * Artifact info: ['This file exists for test purposes only and is not a real SageMaker Model artifact'] + * + * Roughly 25% of the time: + * + * { + * "ContentType": "text/plain", + * "InvokedProductionVariant": "secondVariant" + * } + * Artifact info: ['This file exists for test purposes only and is not a real SageMaker Model artifact'] + * + * Roughly 50% of the time: + * + * { + * "ContentType": "text/plain", + * "InvokedProductionVariant": "thirdVariant" + * } + * Artifact info: No artifacts are present + */ + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-sagemaker-endpoint'); + +const image = sagemaker.ContainerImage.fromAsset(stack, 'ModelImage', { + directory: path.join(__dirname, 'test-image') +}); +const modelData = sagemaker.ModelData.fromAsset(stack, 'ModelData', + path.join(__dirname, 'test-artifacts', 'valid-artifact.tar.gz')); + +const modelWithArtifactAndVpc = new sagemaker.Model(stack, 'ModelWithArtifactAndVpc', { + container: { image, modelData }, + vpc: new ec2.Vpc(stack, 'VPC'), +}); +const modelWithoutArtifactAndVpc = new sagemaker.Model(stack, 'ModelWithoutArtifactAndVpc', { + container: { image }, +}); + +const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { + model: modelWithArtifactAndVpc, + variantName: 'firstVariant', + instanceType: ec2.InstanceType.of(ec2.InstanceClass.M5, ec2.InstanceSize.LARGE), + }, + extraProductionVariants: [{ + model: modelWithArtifactAndVpc, + variantName: 'secondVariant', + }] +}); +endpointConfig.addProductionVariant({ + model: modelWithoutArtifactAndVpc, + variantName: 'thirdVariant', + initialVariantWeight: 2.0, +}); +const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + +const invokerRole = new iam.Role(stack, 'Invoker', { + assumedBy: new iam.AccountRootPrincipal(), +}); +endpoint.grantInvoke(invokerRole); + +const productionVariant = endpoint.findProductionVariant('firstVariant'); +const instanceCount = productionVariant.autoScaleInstanceCount({ + maxCapacity: 3 +}); +instanceCount.scaleOnInvocations('LimitRPS', { + maxRequestsPerSecond: 30, +}); + +productionVariant.metricInvocations().createAlarm(stack, 'InvocationsAlarm', { + threshold: 1, + evaluationPeriods: 2, +}); +productionVariant.metricInvocationsPerInstance().createAlarm(stack, 'InvocationsPerInstanceAlarm', { + threshold: 4, + evaluationPeriods: 5, +}); +productionVariant.metricModelLatency().createAlarm(stack, 'ModelLatencyAlarm', { + threshold: 7, + evaluationPeriods: 8, +}); +productionVariant.metricOverheadLatency().createAlarm(stack, 'OverheadLatencyAlarm', { + threshold: 10, + evaluationPeriods: 11, +}); +productionVariant.metricInvocationResponseCode(sagemaker.InvocationHttpResponseCode.INVOCATION_5XX_ERRORS).createAlarm(stack, 'Invocation5XXErrorsAlarm', { + threshold: 13, + evaluationPeriods: 14, +}); +productionVariant.metricDiskUtilization().createAlarm(stack, 'DiskUtilizationAlarm', { + threshold: 16, + evaluationPeriods: 17, +}); +productionVariant.metricCPUUtilization().createAlarm(stack, 'CPUUtilizationAlarm', { + threshold: 19, + evaluationPeriods: 20, +}); +productionVariant.metricMemoryUtilization().createAlarm(stack, 'MemoryUtilizationAlarm', { + threshold: 22, + evaluationPeriods: 23, +}); +productionVariant.metricGPUUtilization().createAlarm(stack, 'GPUUtilizationAlarm', { + threshold: 25, + evaluationPeriods: 26, +}); +productionVariant.metricGPUMemoryUtilization().createAlarm(stack, 'GPUMemoryUtilizationAlarm', { + threshold: 28, + evaluationPeriods: 29, +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-sagemaker/test/integ.model.expected.json b/packages/@aws-cdk/aws-sagemaker/test/integ.model.expected.json new file mode 100644 index 0000000000000..ab911cc9af50a --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/integ.model.expected.json @@ -0,0 +1,1149 @@ +{ + "Parameters": { + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51": { + "Type": "String", + "Description": "S3 bucket for asset \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + }, + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF": { + "Type": "String", + "Description": "S3 key for asset version \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + }, + "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916ArtifactHashB0F74179": { + "Type": "String", + "Description": "Artifact hash for asset \"126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916\"" + } + }, + "Resources": { + "VPCB9E5F0B4": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC" + } + ] + } + }, + "VPCPublicSubnet1SubnetB4246D30": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet1RouteTableFEE4B781": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableAssociation0B0896DC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + } + } + }, + "VPCPublicSubnet1DefaultRoute91CEF279": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet1EIP6AD938E8": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1NATGatewayE0556630": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet1EIP6AD938E8", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet2Subnet74179F39": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet2RouteTable6F1A15F1": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTableAssociation5A808732": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + } + } + }, + "VPCPublicSubnet2DefaultRouteB7481BBA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet2EIP4947BC00": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2NATGateway3C070193": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet2EIP4947BC00", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet3Subnet631C5E25": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet3RouteTable98AE0E14": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTableAssociation427FE0C6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + } + } + }, + "VPCPublicSubnet3DefaultRouteA0D29D46": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet3EIPAD4BC883": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3NATGatewayD3048F5C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet3EIPAD4BC883", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPrivateSubnet1Subnet8BCA10E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableBE8A6027": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableAssociation347902D1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + } + } + }, + "VPCPrivateSubnet1DefaultRouteAE1D6490": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet1NATGatewayE0556630" + } + } + }, + "VPCPrivateSubnet2SubnetCFCDAA7A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet2RouteTable0A19E10E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTableAssociation0C73D413": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + } + } + }, + "VPCPrivateSubnet2DefaultRouteF4F5CFD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet2NATGateway3C070193" + } + } + }, + "VPCPrivateSubnet3Subnet3EDCD457": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet3RouteTable192186F8": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTableAssociationC28D144E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + } + }, + "VPCPrivateSubnet3DefaultRoute27F311AE": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet3NATGatewayD3048F5C" + } + } + }, + "VPCIGWB7E252D3": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/VPC" + } + ] + } + }, + "VPCVPCGW99B986DC": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "InternetGatewayId": { + "Ref": "VPCIGWB7E252D3" + } + } + }, + "PrimaryContainerModelSecurityGroup06F42014": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-cdk-sagemaker-model/PrimaryContainerModel/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/PrimaryContainerModel" + } + ], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "PrimaryContainerModelRole8762F8B9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sagemaker.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonSageMakerFullAccess" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/PrimaryContainerModel" + } + ] + } + }, + "PrimaryContainerModelRoleDefaultPolicy56D1C738": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "PrimaryContainerModelRoleDefaultPolicy56D1C738", + "Roles": [ + { + "Ref": "PrimaryContainerModelRole8762F8B9" + } + ] + } + }, + "PrimaryContainerModel389512BE": { + "Type": "AWS::SageMaker::Model", + "Properties": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "PrimaryContainerModelRole8762F8B9", + "Arn" + ] + }, + "PrimaryContainer": { + "Image": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:latest" + ] + ] + }, + "ModelDataUrl": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + } + ] + ] + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/PrimaryContainerModel" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "PrimaryContainerModelSecurityGroup06F42014", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + }, + { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + ] + } + }, + "DependsOn": [ + "PrimaryContainerModelRoleDefaultPolicy56D1C738", + "PrimaryContainerModelRole8762F8B9" + ] + }, + "InferencePipelineModelRole6A99C5B3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sagemaker.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonSageMakerFullAccess" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/InferencePipelineModel" + } + ] + } + }, + "InferencePipelineModelRoleDefaultPolicy2AF0CDCF": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ecr:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":repository/aws-cdk/assets" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "InferencePipelineModelRoleDefaultPolicy2AF0CDCF", + "Roles": [ + { + "Ref": "InferencePipelineModelRole6A99C5B3" + } + ] + } + }, + "InferencePipelineModel3564F141": { + "Type": "AWS::SageMaker::Model", + "Properties": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "InferencePipelineModelRole6A99C5B3", + "Arn" + ] + }, + "Containers": [ + { + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:26aab3505e4deba4c58890a2836f781a4fd172b59fdd74732a76d59860df5cc9" + ] + ] + } + }, + { + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:26aab3505e4deba4c58890a2836f781a4fd172b59fdd74732a76d59860df5cc9" + ] + ] + }, + "ModelDataUrl": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + } + ] + ] + } + }, + { + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/aws-cdk/assets:26aab3505e4deba4c58890a2836f781a4fd172b59fdd74732a76d59860df5cc9" + ] + ] + }, + "ModelDataUrl": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3Bucket575C0F51" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters126d48fa0e32fbef5078b9d88658b35ad29d4291eb86675a64c75fa4f1338916S3VersionKeyCBE9ABAF" + } + ] + } + ] + } + ] + ] + } + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-sagemaker-model/InferencePipelineModel" + } + ] + }, + "DependsOn": [ + "InferencePipelineModelRoleDefaultPolicy2AF0CDCF", + "InferencePipelineModelRole6A99C5B3" + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sagemaker/test/integ.model.ts b/packages/@aws-cdk/aws-sagemaker/test/integ.model.ts new file mode 100644 index 0000000000000..f04a2bad2ea00 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/integ.model.ts @@ -0,0 +1,122 @@ +import * as ec2 from "@aws-cdk/aws-ec2"; +import * as ecr_assets from "@aws-cdk/aws-ecr-assets"; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import * as cdk from '@aws-cdk/core'; +import * as path from 'path'; +import * as sagemaker from '../lib'; + +/* + * Stack verification steps: + * aws sagemaker describe-model --model-name + * + * For the single container model, the output will resemble: + * + * { + * "ModelName": "PrimaryContainerModel...", + * "PrimaryContainer": { + * "Image": "...", + * "ModelDataUrl": "https://s3..." + * }, + * "ExecutionRoleArn": "arn:aws:iam::...", + * "VpcConfig": { + * "SecurityGroupIds": [ + * "sg-..." + * ], + * "Subnets": [ + * "subnet-...", + * "subnet-..." + * ] + * }, + * "CreationTime": ..., + * "ModelArn": "arn:aws:sagemaker:...", + * "EnableNetworkIsolation": false + * } + * + * For the inference pipeline model, the output will resemble: + * + * { + * "ModelName": "InferencePipelineModel...", + * "Containers": [ + * { + * "Image": "..." + * }, + * { + * "Image": "...", + * "ModelDataUrl": "https://s3..." + * }, + * { + * "Image": "...", + * "ModelDataUrl": "https://s3..." + * } + * ], + * "ExecutionRoleArn": "arn:aws:iam::...", + * "CreationTime": ..., + * "ModelArn": "arn:aws:sagemaker:...", + * "EnableNetworkIsolation": false + * } + */ + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-sagemaker-model'); + +// Paths to local assets +const dockerfileDirectory = path.join(__dirname, 'test-image'); +const artifactFilePath = path.join(__dirname, 'test-artifacts', 'valid-artifact.tar.gz'); + +/* + * Exercise different variations of ContainerImages, including: + * - EcrImage as vended by ContainerImage.fromEcrRepository + * - AssetImage as vended by ContainerImage.fromAsset + */ + +const ecrImage = sagemaker.ContainerImage.fromEcrRepository( + new ecr_assets.DockerImageAsset(stack, 'EcrImage', { + directory: dockerfileDirectory, + }).repository +); + +const localImage = sagemaker.ContainerImage.fromAsset(stack, 'LocalImage', { + directory: dockerfileDirectory, +}); + +/* + * Exercise different variations of ModelData, including: + * - S3ModelData as vended by ModelData.fromBucket + * - AssetModelData as vended by ModelData.fromAsset + */ + +const artifactAsset = new s3_assets.Asset(stack, 'S3ModelData', { + path: artifactFilePath, +}); +const s3ModelData = sagemaker.ModelData.fromBucket( + artifactAsset.bucket, + artifactAsset.s3ObjectKey +); + +const localModelData = sagemaker.ModelData.fromAsset(stack, 'LocalModelData', artifactFilePath); + +/* + * Use the above images and model data instances to create SageMaker models, including: + * - A single-container model, with a VPC, using the "remote" image and model data references + * - An inference pipeline model, without a VPC, using the "local" image and model data references + */ + +new sagemaker.Model(stack, 'PrimaryContainerModel', { + container: { + image: ecrImage, + modelData: s3ModelData, + }, + vpc: new ec2.Vpc(stack, 'VPC'), +}); + +new sagemaker.Model(stack, 'InferencePipelineModel', { + container: { + image: localImage + }, + extraContainers: [ + { image: localImage, modelData: localModelData }, + { image: localImage, modelData: localModelData } + ], +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.tar b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.tar new file mode 100644 index 0000000000000..b3dd0bd4c1846 Binary files /dev/null and b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.tar differ diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.zip b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.zip new file mode 100644 index 0000000000000..8ab041df79875 Binary files /dev/null and b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/invalid-artifact.zip differ diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/valid-artifact.tar.gz b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/valid-artifact.tar.gz new file mode 100644 index 0000000000000..af6ae9c76e414 Binary files /dev/null and b/packages/@aws-cdk/aws-sagemaker/test/test-artifacts/valid-artifact.tar.gz differ diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-image/Dockerfile b/packages/@aws-cdk/aws-sagemaker/test/test-image/Dockerfile new file mode 100644 index 0000000000000..19a04b5d48dfa --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test-image/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3 + +# The following label allows this image to deployed within an inference pipeline +LABEL com.amazonaws.sagemaker.capabilities.accept-bind-to-port=true + +# Avoid buffering output to expedite log delivery +ENV PYTHONUNBUFFERED=TRUE + +# Default to port 8080 unless SageMaker has passed in its own port (for use within an inference pipline) +ENV SAGEMAKER_BIND_TO_PORT=${SAGEMAKER_BIND_TO_PORT:-8080} +EXPOSE $SAGEMAKER_BIND_TO_PORT + +# Set up the server application +ENV PROGRAM_DIRECTORY=/opt/program +RUN mkdir -p $PROGRAM_DIRECTORY +COPY index.py $PROGRAM_DIRECTORY +ENV PATH="${PROGRAM_DIRECTORY}:${PATH}" +WORKDIR $PROGRAM_DIRECTORY +ENTRYPOINT ["python3", "index.py"] diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-image/index.html b/packages/@aws-cdk/aws-sagemaker/test/test-image/index.html new file mode 100644 index 0000000000000..3b18e512dba79 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test-image/index.html @@ -0,0 +1 @@ +hello world diff --git a/packages/@aws-cdk/aws-sagemaker/test/test-image/index.py b/packages/@aws-cdk/aws-sagemaker/test/test-image/index.py new file mode 100644 index 0000000000000..f5ab0458f1cb1 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test-image/index.py @@ -0,0 +1,48 @@ +#!/usr/bin/python + +""" +This script stands up a lightweight HTTP server listening at the port specified in the +SAGEMAKER_BIND_TO_PORT environment variable. It loads an optional artifact from the path +/opt/ml/model/artifact.txt and returns information about the artifact in response to every +invocation request. Ping requests will always succeed. +""" + +import http.server +import os +import socketserver + +class SimpleSageMakerServer(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/ping': + self.respond(200, 'Healthy') + else: + self.respond(404, 'Not Found') + + def do_POST(self): + if self.path == '/invocations': + self.respond(200, 'Artifact info: {}'.format(ARTIFACT)) + else: + self.respond(404, 'Not Found') + + def respond(self, status, response): + self.send_response(status) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes('{}\n'.format(response), 'utf-8')) + + +PORT = int(os.environ['SAGEMAKER_BIND_TO_PORT']) +ARTIFACT_PATH = '/opt/ml/model/artifact.txt' + +print('Looking for model artifacts') +if (os.path.isfile(ARTIFACT_PATH)): + print('Loading model artifact from {}'.format(ARTIFACT_PATH)) + with open(ARTIFACT_PATH, 'r') as artifact_file: + ARTIFACT = artifact_file.read().splitlines() +else: + print('No model artifact present at {}'.format(ARTIFACT_PATH)) + ARTIFACT = 'No artifacts are present' + +with socketserver.TCPServer(('', PORT), SimpleSageMakerServer) as httpd: + print('Serving requests at port {}'.format(PORT)) + httpd.serve_forever() diff --git a/packages/@aws-cdk/aws-sagemaker/test/test.endpoint-config.ts b/packages/@aws-cdk/aws-sagemaker/test/test.endpoint-config.ts new file mode 100644 index 0000000000000..ba59fa5fb45f1 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test.endpoint-config.ts @@ -0,0 +1,170 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import * as sagemaker from '../lib'; + +export = { + 'When validating stack containing an EndpointConfig': { + 'with more than 10 production variants, an error is recorded'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { + variantName: 'variant', + model + } + }); + for (let i = 0; i < 10; i++) { + endpointConfig.addProductionVariant({ variantName: `variant-${i}`, model }); + } + + // WHEN + const errors = validate(stack); + + // THEN + test.deepEqual(errors.map(e => e.message), ["Can\'t have more than 10 Production Variants"]); + + test.done(); + }, + }, + + 'When adding a production variant to an EndpointConfig': { + 'with too few instances specified, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const when = () => + endpointConfig.addProductionVariant({ + variantName: 'new-variant', + model, + initialInstanceCount: 0, + }); + + // THEN + test.throws(when, /Invalid Production Variant Props: Must have at least one instance/); + + test.done(); + }, + + 'with a negative weight, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const when = () => + endpointConfig.addProductionVariant({ + variantName: 'new-variant', + model, + initialVariantWeight: -1, + }); + + // THEN + test.throws(when, /Invalid Production Variant Props: Cannot have negative variant weight/); + + test.done(); + }, + + 'with an unsupported instance type, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const when = () => + endpointConfig.addProductionVariant({ + variantName: 'new-variant', + model, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.ARM1, ec2.InstanceSize.XLARGE), + }); + + // THEN + test.throws(when, /Invalid Production Variant Props: Invalid instance type for a SageMaker Endpoint Production Variant: a1.xlarge/); + + test.done(); + }, + + 'with a duplicate variant name, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const when = () => endpointConfig.addProductionVariant({ variantName: 'variant', model }); + + // THEN + test.throws(when, /There is already a Production Variant with name 'variant'/); + + test.done(); + }, + }, + + 'When searching an EndpointConfig for a production variant': { + 'that exists, the variant is returned'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const variant = endpointConfig.findProductionVariant('variant'); + + // THEN + test.equal(variant.variantName, 'variant'); + + test.done(); + }, + + 'that does not exist, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, `Model`, `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + + // WHEN + const when = () => endpointConfig.findProductionVariant('missing-variant'); + + // THEN + test.throws(when, /No variant with name: 'missing-variant'/); + + test.done(); + }, + }, + + 'When importing an endpoint configuration by name, the ARN is constructed correctly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, undefined, { + env: + { + region: 'us-west-2', + account: '123456789012' + } + }); + + // WHEN + const endpointConfig = sagemaker.EndpointConfig.fromEndpointConfigName(stack, 'EndpointConfig', 'my-name'); + + // THEN + test.equal(endpointConfig.endpointConfigArn, 'arn:${Token[AWS::Partition.3]}:sagemaker:us-west-2:123456789012:endpoint-config/my-name'); + + test.done(); + }, +}; + +function validate(construct: cdk.IConstruct): cdk.ValidationError[] { + cdk.ConstructNode.prepare(construct.node); + return cdk.ConstructNode.validate(construct.node); +} diff --git a/packages/@aws-cdk/aws-sagemaker/test/test.endpoint.ts b/packages/@aws-cdk/aws-sagemaker/test/test.endpoint.ts new file mode 100644 index 0000000000000..7071d47da1efc --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test.endpoint.ts @@ -0,0 +1,169 @@ +import * as ec2 from "@aws-cdk/aws-ec2"; +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import * as sagemaker from '../lib'; + +export = { + 'When searching an Endpoint for a production variant': { + 'that exists, the variant is returned'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + + // WHEN + const variant = endpoint.findProductionVariant('variant'); + + // THEN + test.equal(variant.variantName, 'variant'); + + test.done(); + }, + + 'that does not exist, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + + // WHEN + const when = () => endpoint.findProductionVariant('missing-variant'); + + // THEN + test.throws(when, /No variant with name: 'missing-variant'/); + + test.done(); + }, + }, + + 'When fetching production variants from an Endpoint': { + 'with one production variant, the variant is returned'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + + // WHEN + const variants: sagemaker.IEndpointProductionVariant[] = endpoint.productionVariants; + + // THEN + test.equal(variants.length, 1); + test.equal(variants[0].variantName, 'variant'); + + test.done(); + } + }, + + 'When importing an endpoint by name, the ARN is constructed correctly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, undefined, { + env: + { + region: 'us-west-2', + account: '123456789012' + } + }); + + // WHEN + const endpoint = sagemaker.Endpoint.fromEndpointName(stack, 'Endpoint', 'my-name'); + + // THEN + test.equal(endpoint.endpointArn, 'arn:${Token[AWS::Partition.3]}:sagemaker:us-west-2:123456789012:endpoint/my-name'); + + test.done(); + }, + + 'When auto-scaling a production variant\'s instance count': { + 'with minimum capacity greater than initial instance count, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model } }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + const variant = endpoint.findProductionVariant('variant'); + + // WHEN + const when = () => variant.autoScaleInstanceCount({ + minCapacity: 2, + maxCapacity: 3, + }); + + // THEN + test.throws(when, /minCapacity cannot be greater than initial instance count: 1/); + + test.done(); + }, + + 'with maximum capacity less than initial instance count, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { variantName: 'variant', model, initialInstanceCount: 2 } }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + const variant = endpoint.findProductionVariant('variant'); + + // WHEN + const when = () => variant.autoScaleInstanceCount({ maxCapacity: 1 }); + + // THEN + test.throws(when, /maxCapacity cannot be less than initial instance count: 2/); + + test.done(); + }, + + 'with burstable instance type, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { + variantName: 'variant', + model, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MEDIUM) + } + }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + const variant = endpoint.findProductionVariant('variant'); + + // WHEN + const when = () => variant.autoScaleInstanceCount({ maxCapacity: 3 }); + + // THEN + test.throws(when, /AutoScaling not supported for burstable instance types like t2.medium/); + + test.done(); + }, + + 'which already has auto-scaling enabled, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelName(stack, 'Model', `model`); + const endpointConfig = new sagemaker.EndpointConfig(stack, 'EndpointConfig', { + productionVariant: { + variantName: 'variant', + model, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.M5, ec2.InstanceSize.LARGE) + } + }); + const endpoint = new sagemaker.Endpoint(stack, 'Endpoint', { endpointConfig }); + const variant = endpoint.findProductionVariant('variant'); + variant.autoScaleInstanceCount({ maxCapacity: 3 }); + + // WHEN + const when = () => variant.autoScaleInstanceCount({ maxCapacity: 3 }); + + // THEN + test.throws(when, /AutoScaling of task count already enabled for this service/); + + test.done(); + }, + }, +}; diff --git a/packages/@aws-cdk/aws-sagemaker/test/test.model-data.ts b/packages/@aws-cdk/aws-sagemaker/test/test.model-data.ts new file mode 100644 index 0000000000000..06adac619e52e --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test.model-data.ts @@ -0,0 +1,50 @@ +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import * as path from 'path'; +import * as sagemaker from '../lib'; + +export = { + 'When creating model data from a local asset': { + 'by supplying a directory, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const when = () => + sagemaker.ModelData.fromAsset(stack, 'ModelData', path.join(__dirname, 'test-artifacts')); + + // THEN + test.throws(when, /Asset must be a .tar.gz file/); + + test.done(); + }, + + 'by supplying a zip file, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const when = () => + sagemaker.ModelData.fromAsset(stack, 'ModelData', path.join(__dirname, 'test-artifacts', 'invalid-artifact.zip')); + + // THEN + test.throws(when, /Asset must be a .tar.gz file/); + + test.done(); + }, + + 'by supplying a file with an unsupported extension, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const when = () => + sagemaker.ModelData.fromAsset(stack, 'ModelData', path.join(__dirname, 'test-artifacts', 'invalid-artifact.tar')); + + // THEN + test.throws(when, /Asset must be a .tar.gz file/); + + test.done(); + }, + }, +}; diff --git a/packages/@aws-cdk/aws-sagemaker/test/test.model.ts b/packages/@aws-cdk/aws-sagemaker/test/test.model.ts new file mode 100644 index 0000000000000..e787b5a40f464 --- /dev/null +++ b/packages/@aws-cdk/aws-sagemaker/test/test.model.ts @@ -0,0 +1,269 @@ +import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as ecr from '@aws-cdk/aws-ecr'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import * as sagemaker from '../lib'; + +export = { + 'When instantiating SageMaker Model': { + 'with more than 5 containers, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const testRepo = ecr.Repository.fromRepositoryName(stack, `testRepo`, `123456789012.dkr.ecr.us-west-2.amazonaws.com/mymodel`); + const container = { image: sagemaker.ContainerImage.fromEcrRepository(testRepo) }; + const extraContainers: sagemaker.ContainerDefinition[] = []; + for (let i = 0; i < 5; i++) { + const containerDefinition = { + image: sagemaker.ContainerImage.fromEcrRepository(testRepo) + }; + extraContainers.push(containerDefinition); + } + + // WHEN + const when = () => new sagemaker.Model(stack, 'Model', { container, extraContainers }); + + // THEN + test.throws(when, /Cannot have more than 5 containers in inference pipeline/); + + test.done(); + }, + + 'with a ContainerImage implementation which adds constructs of its own, the new constructs are present'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + class ConstructCreatingContainerImage extends sagemaker.ContainerImage { + public bind(scope: cdk.Construct, _model: sagemaker.Model): sagemaker.ContainerImageConfig { + new iam.User(scope, 'User', { + userName: 'ExtraConstructUserName' + }); + return { + imageName: 'anything' + }; + } + } + + // WHEN + new sagemaker.Model(stack, 'Model', { + container: { + image: new ConstructCreatingContainerImage() + } + }); + + // THEN + expect(stack).to(haveResource('AWS::SageMaker::Model')); + expect(stack).to(haveResource('AWS::IAM::User', { + UserName: 'ExtraConstructUserName' + })); + + test.done(); + }, + + 'with a VPC': { + 'and security groups, no security group is created'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'testVPC'); + + // WHEN + new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + vpc, + securityGroups: [new ec2.SecurityGroup(stack, 'SG', {vpc})] + }); + + // THEN + expect(stack).notTo(haveResource('AWS::EC2::SecurityGroup', { + GroupDescription: 'Model/ModelSecurityGroup' + })); + + test.done(); + }, + + 'but no security groups, a security group is created'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + vpc: new ec2.Vpc(stack, 'testVPC'), + }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + GroupDescription: 'Model/SecurityGroup' + })); + + test.done(); + }, + + 'and both security groups and allowAllOutbound are specified, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'testVPC'); + + // WHEN + const when = () => + new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + vpc, + securityGroups: [new ec2.SecurityGroup(stack, 'SG', {vpc})], + allowAllOutbound: false + }); + + // THEN + test.throws(when, /Configure 'allowAllOutbound' directly on the supplied SecurityGroups/); + + test.done(); + }, + }, + + 'without a VPC': { + 'but security groups are specified, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpcNotSpecified = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const when = () => + new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + securityGroups: [new ec2.SecurityGroup(stack, 'SG', {vpc: vpcNotSpecified})] + }); + + // THEN + test.throws(when, /Cannot configure 'securityGroups' or 'allowAllOutbound' without configuring a VPC/); + + test.done(); + }, + + 'but allowAllOutbound is specified, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const when = () => + new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + allowAllOutbound: false + }); + + // THEN + test.throws(when, /Cannot configure 'securityGroups' or 'allowAllOutbound' without configuring a VPC/); + + test.done(); + }, + }, + }, + + 'When accessing Connections object': { + 'from a model with no VPC, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const modelWithoutVpc = new sagemaker.Model(stack, 'Model', { + container: {image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo'))}, + }); + + // WHEN + const when = () => modelWithoutVpc.connections; + + // THEN + test.throws(when, /Cannot manage network access without configuring a VPC/); + + test.done(); + }, + + 'from an imported model with no security groups specified, an exception is thrown'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const importedModel = sagemaker.Model.fromModelAttributes(stack, 'Model', { + modelName: 'MyModel' + }); + + // WHEN + const when = () => importedModel.connections; + + // THEN + test.throws(when, /Cannot manage network access without configuring a VPC/); + + test.done(); + } + }, + + 'When adding security group after model instantiation, it is reflected in VpcConfig of Model'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'testVPC'); + const model = new sagemaker.Model(stack, 'Model', { + container: { image: sagemaker.ContainerImage.fromEcrRepository(new ecr.Repository(stack, 'Repo')) }, + vpc, + }); + + // WHEN + model.connections.addSecurityGroup(new ec2.SecurityGroup(stack, 'AdditionalGroup', {vpc})); + + // THEN + expect(stack).to(haveResourceLike('AWS::SageMaker::Model', { + VpcConfig: { + SecurityGroupIds: [ + { + 'Fn::GetAtt': [ + 'ModelSecurityGroup2A7C9E10', + 'GroupId' + ] + }, + { + 'Fn::GetAtt': [ + 'AdditionalGroup4973CFAA', + 'GroupId' + ] + } + ] + } + })); + + test.done(); + }, + + 'When allowing traffic from an imported model with a security group, an S3 egress rule should be present'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const model = sagemaker.Model.fromModelAttributes(stack, 'Model', { + modelName: 'MyModel', + securityGroups: [ec2.SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789', { + allowAllOutbound: false, + })], + }); + + // WHEN + model.connections.allowToAnyIpv4(ec2.Port.tcp(443)); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroupEgress', { + GroupId: 'sg-123456789', + })); + + test.done(); + }, + + 'When importing a model by name, the ARN is constructed correctly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, undefined, { + env: + { + region: 'us-west-2', + account: '123456789012' + } + }); + + // WHEN + const model = sagemaker.Model.fromModelName(stack, 'Model', 'my-name'); + + // THEN + test.equal(model.modelArn, 'arn:${Token[AWS::Partition.3]}:sagemaker:us-west-2:123456789012:model/my-name'); + + test.done(); + }, +};